From d73171b052c82ffce64cb1cdd343b6e311c557ad Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 14:07:41 +0200 Subject: [PATCH] feat(14-05): add tenderRadar i18n namespace (DE + EN) with key-parity guard Introduces the tenderRadar top-level namespace in de.json/en.json covering page, results, filter (incl. Bundesland/CPV division labels), savedSearch, detail, coverage, settings, sourceConfig, rssFeeds and emailAlerts groups. EN translations authored with consistent Vergabe-domain terminology (Ausschreibung->tender, Vergabestelle->contracting authority, Frist-> deadline, Auftragswert->estimated value). tenderRadar-parity.spec.ts enforces recursively-flattened de/en key-set equality so no follow-up edit can silently add a string to only one locale. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/messages/de.json | 253 ++++++++++++++++++ apps/web/src/messages/en.json | 253 ++++++++++++++++++ .../src/messages/tenderRadar-parity.spec.ts | 59 ++++ 3 files changed, 565 insertions(+) create mode 100644 apps/web/src/messages/tenderRadar-parity.spec.ts diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 762a6dd..743dbea 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -571,5 +571,258 @@ "wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.", "unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden." } + }, + "tenderRadar": { + "page": { + "title": "Ausschreibungs-Radar", + "loading": "Lädt…" + }, + "results": { + "colTitle": "Titel", + "colBuyer": "Auftraggeber", + "colDeadline": "Frist", + "colValue": "Wert", + "colPublished": "Veröffentlicht", + "colTriage": "Triage", + "noValue": "keine Wertangabe", + "error": "Ausschreibungen konnten nicht geladen werden.", + "emptyTitle": "Keine Ausschreibungen gefunden.", + "emptyBody": "Passen Sie die Filter an oder deaktivieren Sie „nur noch offene\".", + "markUnread": "Als ungelesen markieren", + "markRead": "Als gelesen markieren", + "readLabel": "Gelesen", + "unreadLabel": "Ungelesen", + "removeFavorite": "Favorit entfernen", + "setFavorite": "Favorit setzen", + "favoriteOn": "★ Favorit", + "favoriteOff": "☆ Favorit", + "showingRange": "Zeige {from}–{to} von {total}", + "prevPage": "← Zurück", + "nextPage": "Weiter →" + }, + "filter": { + "sortPublished": "Veröffentlichungsdatum", + "sortDeadline": "Abgabefrist", + "sortValue": "Auftragswert", + "searchLabel": "Suche", + "searchPlaceholder": "Titel oder Auftraggeber…", + "sortLabel": "Sortierung", + "openOnlyLabel": "Nur noch offene", + "favOnlyLabel": "Nur Favoriten/Merkliste", + "deadlineFromLabel": "Abgabefrist von", + "deadlineToLabel": "Abgabefrist bis", + "plzLabel": "PLZ", + "plzPlaceholder": "z. B. 10", + "bundeslandLabel": "Bundesland", + "bundeslandAll": "Alle Bundesländer", + "cpvLabel": "Branche (CPV)", + "cpvPlaceholder": "z. B. Bau, IT, Gesundheit…", + "cpvRemoveAria": "Division {code} entfernen", + "valueMinLabel": "Auftragswert von (€)", + "valueMinPlaceholder": "min.", + "valueMaxLabel": "Auftragswert bis (€)", + "valueMaxPlaceholder": "max.", + "includeNullValueLabel": "Ohne Wertangabe einschließen", + "bundeslandOptions": { + "DE1": "Baden-Württemberg", + "DE2": "Bayern", + "DE3": "Berlin", + "DE4": "Brandenburg", + "DE5": "Bremen", + "DE6": "Hamburg", + "DE7": "Hessen", + "DE8": "Mecklenburg-Vorpommern", + "DE9": "Niedersachsen", + "DEA": "Nordrhein-Westfalen", + "DEB": "Rheinland-Pfalz", + "DEC": "Saarland", + "DED": "Sachsen", + "DEE": "Sachsen-Anhalt", + "DEF": "Schleswig-Holstein", + "DEG": "Thüringen" + }, + "cpvDivisions": { + "03": "Landwirtschaftliche, fischereiwirtschaftliche, forstwirtschaftliche und verwandte Erzeugnisse", + "09": "Mineralölerzeugnisse, Brennstoffe, Elektrizität und andere Energiequellen", + "14": "Bergbauerzeugnisse, Grundstoffe und verwandte Erzeugnisse", + "15": "Nahrungsmittel, Getränke, Tabak und verwandte Erzeugnisse", + "16": "Land- und forstwirtschaftliche Maschinen", + "18": "Bekleidung, Schuhe, Reiseartikel und Zubehör", + "19": "Leder- und Textilwaren, Kunststoff- und Gummierzeugnisse", + "22": "Druckerzeugnisse und verwandte Erzeugnisse", + "24": "Chemische Erzeugnisse", + "30": "Büromaschinen, Datenverarbeitungsgeräte, -ausrüstung und -material, ausgenommen Möbel und Softwarepakete", + "31": "Elektrische Maschinen, Geräte, Ausrüstungen und Verbrauchsartikel; Beleuchtung", + "32": "Rundfunk-, Fernseh-, Kommunikations-, Telekommunikations- und verwandte Geräte", + "33": "Medizinische Apparate, pharmazeutische Erzeugnisse und Körperpflegeprodukte", + "34": "Transportausrüstung und Transportzubehör", + "35": "Sicherheits-, Feuerlösch-, Polizei- und Verteidigungsausrüstung", + "37": "Musikinstrumente, Sportartikel, Spiele, Spielzeug, Kunsthandwerk, Kunstmaterial und Zubehör", + "38": "Laborgeräte, optische und Präzisionsgeräte (ausgenommen Gläser)", + "39": "Möbel (einschließlich Büromöbel), Einrichtungsgegenstände, Haushaltsgeräte (ausgenommen Beleuchtung) und Reinigungsmittel", + "41": "Gesammeltes und gereinigtes Wasser", + "42": "Industriemaschinen", + "43": "Maschinen für Bergbau, Steinbruch, Bauwesen", + "44": "Baustoffe und zugehörige Artikel", + "45": "Bauarbeiten", + "48": "Softwarepaket und Informationssysteme", + "50": "Reparatur- und Wartungsdienste", + "51": "Installationsdienste (ausgenommen Software)", + "55": "Beherbergungs-, Gaststätten- und Handelsdienstleistungen", + "60": "Transportdienstleistungen (ohne Abfalltransport)", + "63": "Neben- und Hilfstätigkeiten im Verkehr; Reisebüros", + "64": "Post- und Telekommunikationsdienste", + "65": "Versorgungsbetriebe", + "66": "Finanz- und Versicherungsdienstleistungen", + "70": "Dienstleistungen im Zusammenhang mit Immobilien", + "71": "Architektur-, Bau-, Ingenieur- und Prüfdienste", + "72": "IT-Dienste: Beratung, Software-Entwicklung, Internet und Hilfestellung", + "73": "Forschungs- und Entwicklungsdienste sowie zugehörige Beratung", + "75": "Öffentliche Verwaltung, Verteidigung und Sozialversicherung", + "76": "Dienstleistungen im Zusammenhang mit der Öl- und Gasindustrie", + "77": "Landwirtschaftliche, forstwirtschaftliche, gartenbauliche Dienste, Aquakultur und Imkerei", + "79": "Unternehmensbezogene Dienstleistungen: Recht, Marketing, Consulting, Personal, Druck und Sicherheit", + "80": "Dienstleistungen des Bildungswesens und der Ausbildung", + "85": "Dienste im Gesundheits- und Sozialwesen", + "90": "Abwasser- und Abfallbeseitigung, Reinigung und Umweltschutzdienste", + "92": "Dienstleistungen der Freizeitgestaltung, der Kultur und des Sports", + "98": "Sonstige Gemeinschafts-, Sozial- und Individualdienstleistungen" + } + }, + "savedSearch": { + "title": "Suchprofile", + "saveAsLabel": "Aktuelle Filter speichern als", + "namePlaceholder": "Name des Suchprofils…", + "save": "Speichern", + "cancel": "Abbrechen", + "loading": "Lädt…", + "empty": "Noch keine Suchprofile gespeichert.", + "loadTitle": "Suchprofil laden", + "instantAlertTitle": "Sofort-Alert per E-Mail bei neuen Treffern dieses Profils", + "instantAlertLabel": "Sofort-Alert", + "instantAlertAria": "Sofort-Alert für {name}", + "renameTitle": "Umbenennen", + "renameAria": "{name} umbenennen", + "renameConfirmAria": "{name} umbenennen bestätigen", + "renameCancelAria": "Umbenennen abbrechen", + "deleteTitle": "Löschen", + "deleteAria": "{name} löschen", + "errorLoad": "Suchprofile konnten nicht geladen werden.", + "errorSave": "Suchprofil konnte nicht gespeichert werden.", + "errorRename": "Suchprofil konnte nicht umbenannt werden.", + "errorDelete": "Suchprofil konnte nicht gelöscht werden.", + "errorToggleAlert": "Sofort-Alert konnte nicht umgeschaltet werden." + }, + "detail": { + "dialogAria": "Ausschreibungsdetails", + "title": "Ausschreibungsdetails", + "close": "Schließen", + "loading": "Lädt…", + "error": "Ausschreibung konnte nicht geladen werden.", + "fieldTitle": "Titel", + "fieldBuyer": "Auftraggeber", + "fieldRegion": "Region / PLZ / Bundesland", + "fieldCpv": "CPV-Codes", + "noCpv": "keine CPV-Codes angegeben", + "fieldDeadline": "Abgabefrist", + "noDeadline": "keine Frist angegeben", + "fieldValue": "Geschätzter Auftragswert", + "noValue": "keine Wertangabe", + "fieldProcedureType": "Verfahrensart", + "fieldStatus": "Status", + "fieldPublished": "Veröffentlicht", + "mirrorNotice": "Vergabeunterlagen werden nicht lokal gespiegelt — der folgende Link führt direkt zum Original auf dem Quellportal.", + "sourceLink": "{portal}: Zur Ausschreibung auf dem Quellportal →", + "noSourceLink": "{portal}: Kein Quell-Link verfügbar.", + "sourceLinkFallback": "Zur Ausschreibung auf dem Quellportal →", + "noSourceLinkFallback": "Kein Quell-Link verfügbar." + }, + "coverage": { + "label": "Datenabdeckung:", + "text": "Aktuell werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen fehlen noch — eine kurze Trefferliste ist daher kein Fehler.", + "denylistLabel": "Manuell beobachten:", + "denylistText": "Diese Portale lassen laut ihren Nutzungsbedingungen keinen automatisierten Zugriff zu und müssen daher manuell geprüft werden:" + }, + "settings": { + "title": "Ausschreibungs-Radar — Einstellungen", + "rssSectionTitle": "RSS-Feeds", + "rssSectionBody": "Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten gleich, nicht pro Mandant konfigurierbar.", + "emailSectionTitle": "E-Mail-Alerts", + "emailSectionBody": "Postfach, in das Vergabeportale Ihre Benachrichtigungsmails schicken — pro Mandant konfigurierbar, getrennt vom DKV-Rechnungspostfach. Aus diesen Mails erkannte Ausschreibungen sind nur für Ihren Mandanten sichtbar.", + "notificationsSectionTitle": "Benachrichtigungen", + "digestIntervalLabel": "Digest-Intervall", + "digestDaily": "Täglich", + "digestWeekly": "Wöchentlich", + "digestOff": "Aus", + "digestHelp": "Fasst neue Treffer aus Ihren Suchprofilen in einer täglichen oder wöchentlichen Sammel-Mail zusammen. „Aus\" deaktiviert den Digest — Sofort-Alerts pro Suchprofil bleiben unabhängig davon nutzbar.", + "saveSuccess": "Einstellung gespeichert.", + "errorLoad": "Benachrichtigungseinstellung konnte nicht geladen werden", + "errorSave": "Benachrichtigungseinstellung konnte nicht gespeichert werden" + }, + "sourceConfig": { + "errorLoad": "Konfiguration konnte nicht geladen werden", + "errorSave": "Einstellungen konnten nicht gespeichert werden", + "validationError": "Intervall muss zwischen {min} und {max} Minuten liegen.", + "sourceLabel": "Quelle", + "lastIngestedLabel": "Zuletzt erfasster Tag", + "neverIngested": "Noch keine Erfassung", + "pollIntervalLabel": "Abrufintervall (Minuten) *", + "minutesSuffix": "Min.", + "pollIntervalHelp": "Cron-Taktfrequenz ({min}–{max} Min.) — der eigentliche DÖE-Abruf ist zusätzlich über den Tages-Cursor gedrosselt.", + "activeLabel": "Aktiv", + "saving": "Speichert...", + "save": "Speichern", + "saveSuccess": "Einstellungen gespeichert." + }, + "rssFeeds": { + "errorLoad": "RSS-Feeds konnten nicht geladen werden", + "errorAdd": "RSS-Feed konnte nicht gespeichert werden", + "errorRemove": "RSS-Feed konnte nicht entfernt werden", + "validationRequired": "URL und Bezeichnung sind erforderlich.", + "empty": "Noch keine RSS-Feeds hinterlegt.", + "activeLabel": "aktiv", + "inactiveLabel": "inaktiv", + "removeAria": "RSS-Feed \"{label}\" entfernen", + "removing": "Entfernt...", + "remove": "Entfernen", + "urlLabel": "Feed-URL *", + "urlPlaceholder": "https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml", + "labelLabel": "Bezeichnung *", + "labelPlaceholder": "z. B. service-bund", + "adding": "Wird hinzugefügt...", + "add": "Feed hinzufügen" + }, + "emailAlerts": { + "notSaved": "Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und speichern.", + "protocolLabel": "Protokoll *", + "protocolImap": "IMAP", + "protocolExchange": "Exchange", + "hostLabelExchange": "EWS-Endpunkt-URL", + "hostLabelImap": "IMAP-Server", + "hostPlaceholderExchange": "https://mail.firma.de/EWS/Exchange.asmx", + "hostPlaceholderImap": "imap.firma.de", + "portLabel": "Port *", + "encryptionLabel": "Verschlüsselung *", + "encryptionNone": "Keine", + "encryptionStarttls": "STARTTLS", + "encryptionSslTls": "SSL-TLS", + "folderLabel": "Ordner", + "folderPlaceholderExchange": "Inbox", + "folderPlaceholderImap": "INBOX", + "domainLabel": "Windows-Domäne", + "domainPlaceholder": "CONTOSO", + "senderFilterLabel": "Absenderfilter", + "senderFilterPlaceholder": "alerts@vergabeportal.de", + "usernameLabel": "Benutzername", + "passwordLabel": "Passwort", + "passwordPlaceholder": "••••••••", + "showPassword": "Passwort anzeigen", + "hidePassword": "Passwort verbergen", + "activeLabel": "Aktiv", + "saving": "Wird gespeichert...", + "save": "Speichern", + "saveSuccess": "Einstellungen gespeichert.", + "errorSave": "Einstellungen konnten nicht gespeichert werden" + } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index d132688..e920e30 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -571,5 +571,258 @@ "wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.", "unknownFormat": "Unknown format. The file could not be recognized as a certificate." } + }, + "tenderRadar": { + "page": { + "title": "Tender Radar", + "loading": "Loading…" + }, + "results": { + "colTitle": "Title", + "colBuyer": "Contracting Authority", + "colDeadline": "Deadline", + "colValue": "Value", + "colPublished": "Published", + "colTriage": "Triage", + "noValue": "no value stated", + "error": "Could not load tenders.", + "emptyTitle": "No tenders found.", + "emptyBody": "Adjust the filters or disable \"open only\".", + "markUnread": "Mark as unread", + "markRead": "Mark as read", + "readLabel": "Read", + "unreadLabel": "Unread", + "removeFavorite": "Remove favorite", + "setFavorite": "Set favorite", + "favoriteOn": "★ Favorite", + "favoriteOff": "☆ Favorite", + "showingRange": "Showing {from}–{to} of {total}", + "prevPage": "← Back", + "nextPage": "Next →" + }, + "filter": { + "sortPublished": "Publication date", + "sortDeadline": "Deadline", + "sortValue": "Estimated value", + "searchLabel": "Search", + "searchPlaceholder": "Title or contracting authority…", + "sortLabel": "Sort by", + "openOnlyLabel": "Open only", + "favOnlyLabel": "Favorites only", + "deadlineFromLabel": "Deadline from", + "deadlineToLabel": "Deadline to", + "plzLabel": "Postal code", + "plzPlaceholder": "e.g. 10", + "bundeslandLabel": "Federal state", + "bundeslandAll": "All federal states", + "cpvLabel": "Sector (CPV)", + "cpvPlaceholder": "e.g. construction, IT, health…", + "cpvRemoveAria": "Remove division {code}", + "valueMinLabel": "Estimated value from (€)", + "valueMinPlaceholder": "min.", + "valueMaxLabel": "Estimated value to (€)", + "valueMaxPlaceholder": "max.", + "includeNullValueLabel": "Include tenders without a stated value", + "bundeslandOptions": { + "DE1": "Baden-Württemberg", + "DE2": "Bavaria", + "DE3": "Berlin", + "DE4": "Brandenburg", + "DE5": "Bremen", + "DE6": "Hamburg", + "DE7": "Hesse", + "DE8": "Mecklenburg-Western Pomerania", + "DE9": "Lower Saxony", + "DEA": "North Rhine-Westphalia", + "DEB": "Rhineland-Palatinate", + "DEC": "Saarland", + "DED": "Saxony", + "DEE": "Saxony-Anhalt", + "DEF": "Schleswig-Holstein", + "DEG": "Thuringia" + }, + "cpvDivisions": { + "03": "Agricultural, farming, fishing, forestry and related products", + "09": "Petroleum products, fuel, electricity and other sources of energy", + "14": "Mining, basic metals and related products", + "15": "Food, beverages, tobacco and related products", + "16": "Agricultural machinery", + "18": "Clothing, footwear, luggage articles and accessories", + "19": "Leather and textile fabrics, plastic and rubber materials", + "22": "Printed matter and related products", + "24": "Chemical products", + "30": "Office and computing machinery, equipment and supplies except furniture and software packages", + "31": "Electrical machinery, apparatus, equipment and consumables; lighting", + "32": "Radio, television, communication, telecommunication and related equipment", + "33": "Medical equipment, pharmaceutical products and personal care products", + "34": "Transport equipment and auxiliary products to transportation", + "35": "Security, fire-fighting, police and defence equipment", + "37": "Musical instruments, sport goods, games, toys, handicraft, art materials and accessories", + "38": "Laboratory, optical and precision equipment (excl. glasses)", + "39": "Furniture (including office furniture), furnishings, domestic appliances (excl. lighting) and cleaning products", + "41": "Collected and purified water", + "42": "Industrial machinery", + "43": "Machinery for mining, quarrying, construction equipment", + "44": "Construction structures and materials; auxiliary products to construction", + "45": "Construction work", + "48": "Software package and information systems", + "50": "Repair and maintenance services", + "51": "Installation services (except software)", + "55": "Hotel, restaurant and retail trade services", + "60": "Transport services (excl. waste transport)", + "63": "Supporting and auxiliary transport services; travel agency services", + "64": "Postal and telecommunications services", + "65": "Public utilities", + "66": "Financial and insurance services", + "70": "Real estate services", + "71": "Architectural, construction, engineering and inspection services", + "72": "IT services: consulting, software development, Internet and support", + "73": "Research and development services and related consultancy services", + "75": "Administration, defence and social security services", + "76": "Services related to the oil and gas industry", + "77": "Agricultural, forestry, horticultural, aquacultural and apicultural services", + "79": "Business services: law, marketing, consulting, recruitment, printing and security", + "80": "Education and training services", + "85": "Health and social work services", + "90": "Sewage, refuse, cleaning and environmental services", + "92": "Recreational, cultural and sporting services", + "98": "Other community, social and personal services" + } + }, + "savedSearch": { + "title": "Saved Searches", + "saveAsLabel": "Save current filters as", + "namePlaceholder": "Name of the saved search…", + "save": "Save", + "cancel": "Cancel", + "loading": "Loading…", + "empty": "No saved searches yet.", + "loadTitle": "Load saved search", + "instantAlertTitle": "Instant email alert for new matches of this search", + "instantAlertLabel": "Instant alert", + "instantAlertAria": "Instant alert for {name}", + "renameTitle": "Rename", + "renameAria": "Rename {name}", + "renameConfirmAria": "Confirm rename of {name}", + "renameCancelAria": "Cancel rename", + "deleteTitle": "Delete", + "deleteAria": "Delete {name}", + "errorLoad": "Could not load saved searches.", + "errorSave": "Could not save the search.", + "errorRename": "Could not rename the search.", + "errorDelete": "Could not delete the search.", + "errorToggleAlert": "Could not toggle the instant alert." + }, + "detail": { + "dialogAria": "Tender details", + "title": "Tender details", + "close": "Close", + "loading": "Loading…", + "error": "Could not load the tender.", + "fieldTitle": "Title", + "fieldBuyer": "Contracting Authority", + "fieldRegion": "Region / Postal code / Federal state", + "fieldCpv": "CPV codes", + "noCpv": "no CPV codes stated", + "fieldDeadline": "Deadline", + "noDeadline": "no deadline stated", + "fieldValue": "Estimated Value", + "noValue": "no value stated", + "fieldProcedureType": "Procedure Type", + "fieldStatus": "Status", + "fieldPublished": "Published", + "mirrorNotice": "Tender documents are not mirrored locally — the link below leads directly to the original on the source portal.", + "sourceLink": "{portal}: View tender on source portal →", + "noSourceLink": "{portal}: No source link available.", + "sourceLinkFallback": "View tender on source portal →", + "noSourceLinkFallback": "No source link available." + }, + "coverage": { + "label": "Data coverage:", + "text": "Currently only EU-wide above-threshold tenders from the central DÖE source are ingested. Below-threshold tenders from individual contracting authorities are not yet included — a short result list is therefore not a defect.", + "denylistLabel": "Manual watch:", + "denylistText": "These portals do not permit automated access under their terms of use and must therefore be checked manually:" + }, + "settings": { + "title": "Tender Radar — Settings", + "rssSectionTitle": "RSS Feeds", + "rssSectionBody": "Public, platform-wide RSS sources (e.g. service.bund.de or a subreport-elvis municipal feed) — applies equally to all tenants, not configurable per tenant.", + "emailSectionTitle": "Email Alerts", + "emailSectionBody": "Mailbox that tender portals send their notification emails to — configurable per tenant, separate from the DKV invoice mailbox. Tenders detected from these emails are only visible to your tenant.", + "notificationsSectionTitle": "Notifications", + "digestIntervalLabel": "Digest Interval", + "digestDaily": "Daily", + "digestWeekly": "Weekly", + "digestOff": "Off", + "digestHelp": "Summarizes new matches from your saved searches in a daily or weekly digest email. \"Off\" disables the digest — instant alerts per saved search remain usable independently.", + "saveSuccess": "Setting saved.", + "errorLoad": "Could not load notification setting", + "errorSave": "Could not save notification setting" + }, + "sourceConfig": { + "errorLoad": "Could not load configuration", + "errorSave": "Could not save settings", + "validationError": "Interval must be between {min} and {max} minutes.", + "sourceLabel": "Source", + "lastIngestedLabel": "Last Ingested Day", + "neverIngested": "Not yet ingested", + "pollIntervalLabel": "Poll Interval (Minutes) *", + "minutesSuffix": "min.", + "pollIntervalHelp": "Cron tick frequency ({min}–{max} min.) — the actual DÖE poll is additionally throttled by the day cursor.", + "activeLabel": "Active", + "saving": "Saving...", + "save": "Save", + "saveSuccess": "Settings saved." + }, + "rssFeeds": { + "errorLoad": "Could not load RSS feeds", + "errorAdd": "Could not save the RSS feed", + "errorRemove": "Could not remove the RSS feed", + "validationRequired": "URL and label are required.", + "empty": "No RSS feeds configured yet.", + "activeLabel": "active", + "inactiveLabel": "inactive", + "removeAria": "Remove RSS feed \"{label}\"", + "removing": "Removing...", + "remove": "Remove", + "urlLabel": "Feed URL *", + "urlPlaceholder": "https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml", + "labelLabel": "Label *", + "labelPlaceholder": "e.g. service-bund", + "adding": "Adding...", + "add": "Add Feed" + }, + "emailAlerts": { + "notSaved": "Not yet saved — fill in and save the mailbox configuration.", + "protocolLabel": "Protocol *", + "protocolImap": "IMAP", + "protocolExchange": "Exchange", + "hostLabelExchange": "EWS Endpoint URL", + "hostLabelImap": "IMAP Server", + "hostPlaceholderExchange": "https://mail.company.com/EWS/Exchange.asmx", + "hostPlaceholderImap": "imap.company.com", + "portLabel": "Port *", + "encryptionLabel": "Encryption *", + "encryptionNone": "None", + "encryptionStarttls": "STARTTLS", + "encryptionSslTls": "SSL-TLS", + "folderLabel": "Folder", + "folderPlaceholderExchange": "Inbox", + "folderPlaceholderImap": "INBOX", + "domainLabel": "Windows Domain", + "domainPlaceholder": "CONTOSO", + "senderFilterLabel": "Sender Filter", + "senderFilterPlaceholder": "alerts@tenderportal.com", + "usernameLabel": "Username", + "passwordLabel": "Password", + "passwordPlaceholder": "••••••••", + "showPassword": "Show password", + "hidePassword": "Hide password", + "activeLabel": "Active", + "saving": "Saving...", + "save": "Save", + "saveSuccess": "Settings saved.", + "errorSave": "Could not save settings" + } } } diff --git a/apps/web/src/messages/tenderRadar-parity.spec.ts b/apps/web/src/messages/tenderRadar-parity.spec.ts new file mode 100644 index 0000000..4a02b34 --- /dev/null +++ b/apps/web/src/messages/tenderRadar-parity.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import de from './de.json'; +import en from './en.json'; + +/** + * tenderRadar-parity.spec — Plan 14-05 (CONFIG-03, D-10). Structural + * de/en key-set equality guard for the `tenderRadar` i18n namespace: fails + * listing any key present in one locale but missing in the other. Does NOT + * assert on translation content — only that both locales define the exact + * same set of (recursively-flattened) keys, so a future edit can never add + * a string to one locale and silently forget the other. + */ + +/** + * Recursively flattens a nested message object into dot-joined leaf key + * paths (e.g. `{ filter: { plzLabel: '...' } }` -> `['filter.plzLabel']`). + */ +function flattenKeys(obj: unknown, prefix = ''): string[] { + if (obj === null || typeof obj !== 'object') { + return [prefix]; + } + return Object.entries(obj as Record).flatMap(([key, value]) => + flattenKeys(value, prefix ? `${prefix}.${key}` : key), + ); +} + +describe('tenderRadar i18n namespace — de/en key parity', () => { + it('de.json and en.json both define a tenderRadar namespace', () => { + expect(de).toHaveProperty('tenderRadar'); + expect(en).toHaveProperty('tenderRadar'); + }); + + it('has an identical (recursively-flattened) key set in de and en', () => { + const deKeys = flattenKeys(de.tenderRadar).sort(); + const enKeys = flattenKeys(en.tenderRadar).sort(); + + const missingInEn = deKeys.filter((key) => !enKeys.includes(key)); + const missingInDe = enKeys.filter((key) => !deKeys.includes(key)); + + expect(missingInEn, `Keys present in de.json but missing in en.json: ${missingInEn.join(', ')}`).toEqual([]); + expect(missingInDe, `Keys present in en.json but missing in de.json: ${missingInDe.join(', ')}`).toEqual([]); + expect(deKeys).toEqual(enKeys); + }); + + it('every leaf value under tenderRadar is a non-empty string in both locales', () => { + const checkLeaves = (obj: unknown, path = '') => { + if (obj === null || typeof obj !== 'object') { + expect(typeof obj, `${path} should be a string`).toBe('string'); + expect((obj as string).length, `${path} should not be empty`).toBeGreaterThan(0); + return; + } + for (const [key, value] of Object.entries(obj as Record)) { + checkLeaves(value, path ? `${path}.${key}` : key); + } + }; + checkLeaves(de.tenderRadar); + checkLeaves(en.tenderRadar); + }); +});