Ver Fonte

Look up makes' own fault codes, share waveforms, research fixes and import codes (#508)

Adds makes' own codes (BMW, and any make workshops import), shared PicoScope waveforms, AI research of fixes, code imports from a spreadsheet or JSON, Help us improve, a Fault codes sidebar link and PostHog events for the fault code pages.
Bernt Christian Egeland há 1 dia atrás
pai
commit
3b89f3d5bc
90 ficheiros alterados com 13658 adições e 364 exclusões
  1. 351 3
      messages/de/faultCodes.json
  2. 351 3
      messages/en/faultCodes.json
  3. 351 3
      messages/es/faultCodes.json
  4. 351 3
      messages/fr/faultCodes.json
  5. 351 3
      messages/it/faultCodes.json
  6. 351 3
      messages/lt/faultCodes.json
  7. 351 3
      messages/nb/faultCodes.json
  8. 351 3
      messages/nl/faultCodes.json
  9. 351 3
      messages/pl/faultCodes.json
  10. 351 3
      messages/pt-BR/faultCodes.json
  11. 351 3
      messages/pt-PT/faultCodes.json
  12. 351 3
      messages/ru/faultCodes.json
  13. 351 3
      messages/tr/faultCodes.json
  14. 328 0
      src/__tests__/features/fault-codes/code-import.test.tsx
  15. 115 0
      src/__tests__/features/fault-codes/codes.test.ts
  16. 164 0
      src/__tests__/features/fault-codes/help-improve.test.tsx
  17. 489 2
      src/__tests__/features/fault-codes/pages.test.tsx
  18. 119 3
      src/__tests__/features/fault-codes/service.test.ts
  19. 52 0
      src/__tests__/features/fault-codes/waveform-csv.test.ts
  20. 387 0
      src/__tests__/features/fault-codes/waveforms.test.tsx
  21. 155 0
      src/__tests__/features/integrations/torqvoice-codes.test.ts
  22. 58 0
      src/app/(authenticated)/fault-codes/oem/[make]/[code]/page.tsx
  23. 5 0
      src/app/(authenticated)/fault-codes/page.tsx
  24. 1 0
      src/app/(authenticated)/layout.tsx
  25. 10 2
      src/app/(public)/p/[token]/fix-handoff-client.tsx
  26. 17 2
      src/app/(public)/p/[token]/page.tsx
  27. 10 0
      src/app/api/protected/fault-codes/fixes/route.ts
  28. 41 0
      src/app/api/protected/fault-codes/import/check/route.ts
  29. 33 0
      src/app/api/protected/fault-codes/import/example/route.ts
  30. 42 0
      src/app/api/protected/fault-codes/import/route.ts
  31. 52 0
      src/app/api/protected/fault-codes/waveforms/[id]/files/[role]/route.ts
  32. 176 0
      src/app/api/protected/fault-codes/waveforms/route.ts
  33. 10 5
      src/app/api/public/fix-handoff/[token]/route.ts
  34. 15 0
      src/components/app-sidebar.tsx
  35. 2 2
      src/components/page-header.tsx
  36. 7 1
      src/features/fault-codes/Actions/addFaultCodeFindings.ts
  37. 1 0
      src/features/fault-codes/Actions/createFixPhoneLink.ts
  38. 12 0
      src/features/fault-codes/Actions/getCodeImports.ts
  39. 18 5
      src/features/fault-codes/Actions/getFaultCodeOverview.ts
  40. 33 0
      src/features/fault-codes/Actions/getOemFaultCode.ts
  41. 2 2
      src/features/fault-codes/Actions/lookupFaultCodes.ts
  42. 19 0
      src/features/fault-codes/Actions/rateFaultCodeResearch.ts
  43. 33 0
      src/features/fault-codes/Actions/researchFaultCode.ts
  44. 29 12
      src/features/fault-codes/Actions/searchFaultCodes.ts
  45. 78 0
      src/features/fault-codes/Actions/sendFaultCodeFeedback.ts
  46. 10 5
      src/features/fault-codes/Actions/suggestFaultCodes.ts
  47. 32 0
      src/features/fault-codes/Actions/withdrawFaultCodeWaveform.ts
  48. 3 0
      src/features/fault-codes/Components/DescriptionSections.tsx
  49. 255 40
      src/features/fault-codes/Components/FaultCodeDetail.tsx
  50. 9 3
      src/features/fault-codes/Components/FaultCodeEntryDialog.tsx
  51. 187 41
      src/features/fault-codes/Components/FaultCodeLookup.tsx
  52. 40 0
      src/features/fault-codes/Components/FaultCodeOemView.tsx
  53. 35 8
      src/features/fault-codes/Components/FaultCodeSheet.tsx
  54. 53 9
      src/features/fault-codes/Components/FaultCodesNotice.tsx
  55. 7 0
      src/features/fault-codes/Components/FaultCodesOnJob.tsx
  56. 9 2
      src/features/fault-codes/Components/FixPhotos.tsx
  57. 201 0
      src/features/fault-codes/Components/HelpImproveDialog.tsx
  58. 567 0
      src/features/fault-codes/Components/ImportCodesDialog.tsx
  59. 21 0
      src/features/fault-codes/Components/Likelihood.tsx
  60. 130 0
      src/features/fault-codes/Components/MakeCombobox.tsx
  61. 104 0
      src/features/fault-codes/Components/OemWhereUsed.tsx
  62. 159 0
      src/features/fault-codes/Components/PhoneCode.tsx
  63. 2 0
      src/features/fault-codes/Components/ReportMeaningPopover.tsx
  64. 338 0
      src/features/fault-codes/Components/ResearchCard.tsx
  65. 20 4
      src/features/fault-codes/Components/ShareFixCard.tsx
  66. 74 122
      src/features/fault-codes/Components/ShareFixDialog.tsx
  67. 679 0
      src/features/fault-codes/Components/ShareWaveformDialog.tsx
  68. 267 0
      src/features/fault-codes/Components/WaveformChart.tsx
  69. 66 0
      src/features/fault-codes/Components/WaveformParts.tsx
  70. 359 0
      src/features/fault-codes/Components/WaveformViewer.tsx
  71. 260 0
      src/features/fault-codes/Components/WaveformsCard.tsx
  72. 11 2
      src/features/fault-codes/Components/WorkshopFixesCard.tsx
  73. 261 0
      src/features/fault-codes/Lib/codeImportFile.server.ts
  74. 124 0
      src/features/fault-codes/Lib/codeImportRequest.server.ts
  75. 157 5
      src/features/fault-codes/Lib/codes.ts
  76. 3 2
      src/features/fault-codes/Lib/fixDrafts.server.ts
  77. 8 3
      src/features/fault-codes/Lib/lookups.ts
  78. 119 0
      src/features/fault-codes/Lib/makes.ts
  79. 68 0
      src/features/fault-codes/Lib/oem.ts
  80. 102 18
      src/features/fault-codes/Lib/page.server.ts
  81. 436 10
      src/features/fault-codes/Lib/service.server.ts
  82. 89 1
      src/features/fault-codes/Lib/types.ts
  83. 106 0
      src/features/fault-codes/Lib/waveformCsv.ts
  84. 145 6
      src/features/fault-codes/Schema/faultCodeSchema.ts
  85. 294 0
      src/features/integrations/Lib/types.ts
  86. 40 4
      src/features/search/Components/SearchCommand.tsx
  87. 2 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  88. 669 0
      src/integrations/torqvoice-codes/server.ts
  89. 41 0
      src/lib/analytics.ts
  90. 20 4
      src/lib/photo-handoff.ts

+ 351 - 3
messages/de/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Herstellerangaben",
     "manufacturer": "Herstellerangaben",
     "related": "Zusammen mit anderen Codes",
     "related": "Zusammen mit anderen Codes",
     "diagnosis": "Diagnosetipp",
     "diagnosis": "Diagnosetipp",
+    "common_issues": "Häufige Probleme",
+    "resolution": "Behebung",
+    "advisory": "Hinweis",
     "other": "Gut zu wissen"
     "other": "Gut zu wissen"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modell",
     "model": "Modell",
     "year": "Baujahr",
     "year": "Baujahr",
     "vehicleHint": "Geteilt werden nur Marke, Modell, Baujahr und Motor, nie Kennzeichen, FIN, Kunde oder der Name Ihrer Werkstatt.",
     "vehicleHint": "Geteilt werden nur Marke, Modell, Baujahr und Motor, nie Kennzeichen, FIN, Kunde oder der Name Ihrer Werkstatt.",
+    "meaning": "Bedeutung",
+    "meaningHint": "Dieser Code bedeutet in anderen Steuergeräten etwas anderes. Ihr Fix wird nur für diese Bedeutung geteilt.",
     "vehicleHintMarine": "Geteilt werden nur Marke, Modell, Baujahr und Motor, nie Kennzeichen, Rumpfnummer, Kunde oder der Name Ihrer Werkstatt.",
     "vehicleHintMarine": "Geteilt werden nur Marke, Modell, Baujahr und Motor, nie Kennzeichen, Rumpfnummer, Kunde oder der Name Ihrer Werkstatt.",
     "send": "Lösung teilen",
     "send": "Lösung teilen",
     "sent": "Danke. Ihre Lösung wartet auf Prüfung.",
     "sent": "Danke. Ihre Lösung wartet auf Prüfung.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Codes hinzufügen} one {# Code hinzufügen} other {# Codes hinzufügen}}",
     "add": "{count, plural, =0 {Codes hinzufügen} one {# Code hinzufügen} other {# Codes hinzufügen}}",
     "added": "{count, plural, one {# Fehlercode hinzugefügt} other {# Fehlercodes hinzugefügt}}",
     "added": "{count, plural, one {# Fehlercode hinzugefügt} other {# Fehlercodes hinzugefügt}}",
     "skipped": "{count, plural, one {# Code war schon an diesem Auftrag} other {# Codes waren schon an diesem Auftrag}}",
     "skipped": "{count, plural, one {# Code war schon an diesem Auftrag} other {# Codes waren schon an diesem Auftrag}}",
-    "failed": "Die Codes konnten nicht hinzugefügt werden"
+    "failed": "Die Codes konnten nicht hinzugefügt werden",
+    "descriptionOem": "Tippen oder fügen Sie Codes vom Diagnosegerät ein, OBD-Codes wie P0420 oder BMW-Codes wie 2A82. Jeder wird eine Beobachtung an diesem Auftrag, mit gespeicherter Bedeutung.",
+    "placeholderOem": "P0420, 2A82 gespeichert"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Gesehen {date}",
     "seenBefore": "Gesehen {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Fehlercodes sind neu",
     "title": "Fehlercodes sind neu",
-    "body": "Wir fügen laufend Codes hinzu, daher ist noch nicht jeder Code hier. Derzeit sind die standardisierten OBD-II-Codes enthalten; herstellerspezifische Codes noch nicht."
+    "body": "Enthalten sind die standardisierten OBD-II-Codes. Wir arbeiten daran, auch die eigenen Codes der Hersteller aufzunehmen, und es kommen laufend neue Codes hinzu. Sie wissen, wo die Codes einer Marke zu finden sind? Sagen Sie es uns über „Helfen Sie uns“.",
+    "bodyBmw": "Enthalten sind die standardisierten OBD-II-Codes und die eigenen Codes von BMW, die auch MINI und Rolls-Royce verwenden. Wir arbeiten daran, weitere Marken aufzunehmen, und es kommen laufend neue Codes hinzu. Sie wissen, wo die Codes einer Marke zu finden sind? Sagen Sie es uns über „Helfen Sie uns“.",
+    "bodyMakes": "Enthalten sind die standardisierten OBD-II-Codes und die eigenen Codes von {makes}. Wir arbeiten daran, weitere Marken aufzunehmen, und es kommen laufend neue Codes hinzu. Sie wissen, wo die Codes einer Marke zu finden sind? Sagen Sie es uns über „Helfen Sie uns“."
   },
   },
   "support": {
   "support": {
     "button": "Support kontaktieren",
     "button": "Support kontaktieren",
@@ -245,6 +254,345 @@
     "done": "Wenn Sie fertig sind, kehren Sie zum Computer zurück und teilen Sie die Lösung.",
     "done": "Wenn Sie fertig sind, kehren Sie zum Computer zurück und teilen Sie die Lösung.",
     "privacy": "Halten Sie Gesichter, Kennzeichen und Kundendaten aus dem Bild.",
     "privacy": "Halten Sie Gesichter, Kennzeichen und Kundendaten aus dem Bild.",
     "expired": "Dieser Code ist abgelaufen. Zeigen Sie am Computer einen neuen an.",
     "expired": "Dieser Code ist abgelaufen. Zeigen Sie am Computer einen neuen an.",
-    "invalid": "Dieser Code funktioniert nicht. Zeigen Sie am Computer einen neuen an."
+    "invalid": "Dieser Code funktioniert nicht. Zeigen Sie am Computer einen neuen an.",
+    "waveform": {
+      "title": "Foto vom Oszilloskop-Bildschirm",
+      "forCode": "für die Messkurve, die Sie am Computer teilen",
+      "intro": "Machen oder wählen Sie ein Foto vom Oszilloskop-Bildschirm. Es erscheint am Computer.",
+      "full": "Eine Messkurve hat ein Foto. Entfernen Sie es am Computer, um ein neues zu machen.",
+      "done": "Gehen Sie zurück zum Computer und teilen Sie die Messkurve.",
+      "added": "Foto hinzugefügt"
+    }
+  },
+  "oem": {
+    "badge": "{make}-Code",
+    "summary": "{count, plural, one {# Bedeutung} other {# Bedeutungen}}. Ein BMW-Code kann in jedem Steuergerät etwas anderes bedeuten.",
+    "matches": "Passt zu diesem Fahrzeug",
+    "noMatch": "Keine dieser Bedeutungen ist für den Motor dieses Fahrzeugs aufgeführt. Prüfen Sie, aus welchem Steuergerät Ihr Diagnosegerät den Code gelesen hat.",
+    "inEnglish": "Englischer Text von {make}: {text}",
+    "bmwText": "Text von {make}: {text}",
+    "units": "Steuergeräte",
+    "moreUnits": "+{count} weitere",
+    "otherChassis": "Weitere Baureihen",
+    "years": "{from} bis {to}",
+    "yearsFrom": "ab {from}",
+    "source": "Die Bedeutungen stammen aus den Diagnosetexten von BMW, die meisten auf Deutsch.",
+    "notServed": "{make}-Codes sind noch nicht verfügbar.",
+    "notFound": "{code} ist nicht in den {make}-Daten.",
+    "whereUsed": "Wo {make} ihn verwendet",
+    "models": "Modelle",
+    "engineList": "Motoren",
+    "fewerUnits": "Weniger anzeigen",
+    "fitsThisCar": "Dieses Fahrzeug",
+    "meanings": "Bedeutungen",
+    "otherMeanings": "und {count, plural, one {# weitere Bedeutung} other {# weitere Bedeutungen}}"
+  },
+  "research": {
+    "inviteTitle": "Lösung online recherchieren",
+    "invite": "Wir durchsuchen Herstellerunterlagen, Servicemitteilungen und Werkstattforen nach {code}. Gespeichert wird nur eine konkrete Antwort mit Quellen, und eine Person bei Torqvoice prüft sie.",
+    "button": "Lösung recherchieren",
+    "working": "Recherche zu {code}",
+    "steps": {
+      "bulletins": "Herstellerunterlagen und Servicemitteilungen durchsuchen",
+      "forums": "Werkstattforen nach bestätigten Lösungen lesen",
+      "agree": "Prüfen, ob die Quellen übereinstimmen",
+      "writing": "Schritte zusammenfassen"
+    },
+    "workingHint": "Das dauert ein bis zwei Minuten. Was gefunden wird, bleibt für alle Werkstätten beim Code.",
+    "runningTitle": "Wird gerade recherchiert",
+    "runningHint": "Eine andere Werkstatt hat eben danach gefragt. Schauen Sie in einer Minute wieder nach.",
+    "checkAgain": "Erneut prüfen",
+    "title": "Recherchierte Lösung",
+    "pending": "Wartet auf Prüfung durch eine Person",
+    "published": "Von Torqvoice geprüft",
+    "pendingHint": "Von KI aus den Quellen unten gefunden. Prüfen Sie es am Fahrzeug, bevor Sie Teile tauschen.",
+    "sources": "Quellen",
+    "helpfulQuestion": "Hat das geholfen?",
+    "helpfulYes": "Ja, das hat geholfen",
+    "helpfulNo": "Nein, hat es nicht",
+    "thanks": "Danke, notiert",
+    "missedTitle": "Keine verlässliche Lösung online gefunden",
+    "missed": "Zu {code} wurde nichts Konkretes gefunden, daher wurde nichts gespeichert. Wir haben notiert, dass Werkstätten diesen Code brauchen.",
+    "searchedOn": "Gesucht am {date}",
+    "shareInstead": "Ihre Lösung teilen",
+    "errors": {
+      "busy": "Dieser Code wird gerade recherchiert. Schauen Sie in einer Minute wieder nach.",
+      "limited": "Ihre Werkstatt hat ihre Recherchen für heute aufgebraucht. Versuchen Sie es morgen wieder.",
+      "failed": "Die Recherche konnte gerade nicht laufen. Versuchen Sie es später erneut."
+    }
+  },
+  "waveforms": {
+    "title": "Messkurven",
+    "description": "Oszilloskop-Aufnahmen anderer Werkstätten, Gutkurven und Fehlerkurven, zum Vergleich mit Ihrer Messung.",
+    "share": "Messkurve teilen",
+    "unavailable": "Messkurven sind gerade nicht verfügbar.",
+    "empty": "Noch keine Messkurven für {code}",
+    "emptyHint": "Haben Sie eine Gutkurve? Teilen Sie sie, dann hat der nächste Techniker mit diesem Code etwas zum Vergleichen.",
+    "filter": {
+      "label": "Anzeigen",
+      "all": "Alle",
+      "good": "Gutkurve",
+      "faulty": "Fehlerhaft"
+    },
+    "unknownVehicle": "Fahrzeug nicht angegeben",
+    "unknownVehicleMarine": "Boot nicht angegeben",
+    "status": {
+      "pending": "Wartet auf Prüfung",
+      "published": "Veröffentlicht",
+      "rejected": "Nicht veröffentlicht"
+    },
+    "hasRecording": "PicoScope-Datei zum Herunterladen",
+    "hasCsv": "CSV-Export zum Herunterladen",
+    "kind": {
+      "good": "Gutkurve",
+      "faulty": "Fehlerhaft"
+    },
+    "condition": {
+      "key_on": "Zündung an, Motor aus",
+      "cranking": "Startvorgang",
+      "idle": "Leerlauf",
+      "rpm_2500": "2500 1/min",
+      "driving": "Fahrt",
+      "drivingMarine": "In Fahrt",
+      "under_load": "Unter Last"
+    },
+    "chart": {
+      "label": "Messkurve von {channels} über {span}",
+      "perDivision": "{value}/Div",
+      "noSample": "kein Messwert",
+      "between": "{lo} bis {hi}"
+    },
+    "view": {
+      "label": "Anzeigen",
+      "trace": "Messkurve",
+      "picture": "Bild vom Bildschirm"
+    },
+    "sharedOn": "Geteilt am {date}",
+    "reason": "Grund: {reason}",
+    "compare": {
+      "label": "Vergleichen mit",
+      "none": "Nichts",
+      "close": "Vergleich beenden"
+    },
+    "note": "Hinweise",
+    "writtenIn": "Geschrieben auf {language}",
+    "files": {
+      "title": "Dateien",
+      "psdata": "PicoScope-Aufnahme",
+      "csv": "CSV-Export",
+      "pssettings": "Oszilloskop-Einstellungen",
+      "openHint": "Öffnen Sie die Aufnahme in PicoScope, um zu zoomen, zu messen und jeden Kanal zu sehen."
+    },
+    "nothingToDraw": "Es wurde nur die Aufnahme geteilt. Laden Sie sie herunter und öffnen Sie sie in PicoScope.",
+    "openPicture": "Bild in voller Größe öffnen",
+    "close": "Schließen",
+    "withdraw": "Zurückziehen",
+    "withdrawTitle": "Diese Messkurve zurückziehen?",
+    "withdrawConfirm": "Sie wird mit ihren Dateien gelöscht, und keine andere Werkstatt sieht sie.",
+    "withdrawn": "Messkurve zurückgezogen",
+    "withdrawFailed": "Die Messkurve konnte gerade nicht zurückgezogen werden. Versuchen Sie es gleich noch einmal.",
+    "shareDialog": {
+      "title": "Messkurve für {code} teilen",
+      "description": "Andere Werkstätten sehen sie, sobald eine Person bei Torqvoice sie geprüft hat.",
+      "meaningHint": "Dieser Code bedeutet in anderen Steuergeräten etwas anderes. Ihre Messkurve wird nur für diese Bedeutung geteilt.",
+      "kind": "Was zeigt sie?",
+      "kindHint": {
+        "good": "So sieht es an einem intakten Fahrzeug aus",
+        "goodMarine": "So sieht es an einem intakten Motor aus",
+        "faulty": "So sah es mit dem Fehler aus",
+        "faultyMarine": "So sah es mit dem Fehler aus"
+      },
+      "measured": "Was haben Sie gemessen?",
+      "measuredPlaceholder": "Kurbelwellensensor und Einlassnockenwelle",
+      "measurements": {
+        "crank": "Kurbelwellensensor",
+        "camCrank": "Nockenwelle und Kurbelwelle",
+        "injector": "Einspritzdüse",
+        "ignition": "Zündspule",
+        "compression": "Relative Kompression",
+        "lambda": "Lambdasonde",
+        "maf": "Luftmassenmesser",
+        "alternator": "Lichtmaschinen-Welligkeit",
+        "canBus": "CAN-Bus",
+        "fuelPump": "Kraftstoffpumpenstrom"
+      },
+      "condition": "Wie lief der Motor? (optional)",
+      "files": "Dateien",
+      "filesHint": "Eine Datei genügt. Mit der PicoScope-Datei können andere sie in PicoScope öffnen; mit einem CSV-Export oder einem Bild sehen sie sie direkt hier.",
+      "phone": {
+        "button": "Foto vom Handy",
+        "title": "Fotografieren Sie den Oszilloskop-Bildschirm",
+        "body": "Scannen Sie den Code mit der Handykamera und machen Sie ein Foto vom Bildschirm. Es erscheint hier, sobald es ankommt.",
+        "received": "Das Foto von Ihrem Handy ist angekommen"
+      },
+      "drop": "PicoScope-Datei hier ablegen oder zum Auswählen klicken",
+      "dropMore": "Weitere Datei hinzufügen",
+      "dropTypes": ".psdata, .pssettings, ein CSV-Export oder ein Bild vom Bildschirm",
+      "role": {
+        "psdata": "PicoScope-Aufnahme",
+        "csv": "CSV-Export",
+        "image": "Bild vom Bildschirm",
+        "pssettings": "Oszilloskop-Einstellungen"
+      },
+      "csvNotScope": "Das ist kein Oszilloskop-Export. Speichern Sie ihn in PicoScope erneut als CSV.",
+      "remove": "{name} entfernen",
+      "fromPhone": "Von Ihrem Handy",
+      "previewHint": "So sehen andere Werkstätten sie.",
+      "csvTip": "Tipp: Fügen Sie auch einen CSV-Export hinzu, damit andere die Messkurve hier ohne PicoScope sehen. In PicoScope über Speichern unter und CSV.",
+      "note": "Hinweise (optional)",
+      "notePlaceholder": "Wo Sie gemessen haben, welcher Bereich eingestellt war und worauf man achten muss. Zum Beispiel: Pin 2 am Sensorstecker rückseitig abgegriffen, die Zahnlücke erscheint einmal pro Umdrehung.",
+      "notePlaceholderMarine": "Wo Sie gemessen haben, welcher Bereich eingestellt war und worauf man achten muss.",
+      "sending": "Wird gesendet {percent} %",
+      "send": "Messkurve teilen",
+      "sent": "Danke. Ihre Messkurve wartet auf Prüfung.",
+      "missing": {
+        "kind": "Wählen Sie Gutkurve oder fehlerhaft",
+        "title": "Geben Sie an, was Sie gemessen haben",
+        "file": "Fügen Sie eine Datei oder ein Bild hinzu",
+        "csv": "Entfernen Sie die CSV, die kein Oszilloskop-Export ist"
+      },
+      "notAScopeFile": "{name} ist weder eine PicoScope-Datei noch ein CSV-Export oder ein Bild",
+      "fileTooBig": "{name} ist größer als {size}",
+      "errors": {
+        "failed": "Ihre Messkurve konnte gerade nicht gesendet werden. Versuchen Sie es gleich noch einmal.",
+        "photo": "Das Bild konnte nicht gelesen werden. Versuchen Sie ein anderes.",
+        "csv": "Die CSV ist kein Oszilloskop-Export. Speichern Sie sie in PicoScope erneut als CSV.",
+        "too_large": "Die Dateien sind zu groß.",
+        "no_file": "Fügen Sie die Aufnahme, einen CSV-Export oder ein Bild hinzu.",
+        "paused": "Das Teilen von Messkurven ist gerade pausiert. Versuchen Sie es später.",
+        "demo": "Das Teilen von Messkurven ist in der Demo ausgeschaltet."
+      }
+    }
+  },
+  "improve": {
+    "button": "Helfen Sie uns",
+    "title": "Helfen Sie uns, die Fehlercodes zu verbessern",
+    "description": "Wissen Sie, wo die eigenen Codes einer Marke veröffentlicht sind, haben Sie einen Fehler entdeckt oder eine Idee? Schreiben Sie uns. Es geht direkt an das Torqvoice-Team.",
+    "topic": "Worum geht es? (optional)",
+    "topics": {
+      "makes": "Codes, die fehlen",
+      "wrong": "Etwas stimmt nicht",
+      "source": "Eine gute Quelle oder ein Link",
+      "idea": "Eine Idee"
+    },
+    "message": "Ihre Nachricht",
+    "placeholders": {
+      "makes": "Wo man sie findet, zum Beispiel ein Link zur Codeliste der Marke, und für welche Modelle sie gilt.",
+      "wrong": "Welcher Code, was dort steht und was dort stehen sollte. Ein Link zur Fundstelle hilft.",
+      "source": "Fügen Sie den Link ein und schreiben Sie, was darin steht.",
+      "idea": "Was würde die Fehlercodes in Ihrer Werkstatt nützlicher machen? Links sind willkommen."
+    },
+    "links": "Links in Ihrer Nachricht",
+    "about": "Zu {code}",
+    "leaveOut": "Weglassen",
+    "replyHint": "Wir antworten an die E-Mail-Adresse Ihres Kontos.",
+    "cancel": "Abbrechen",
+    "send": "Senden",
+    "sent": "Danke. Ihre Nachricht ist auf dem Weg zum Torqvoice-Team.",
+    "errors": {
+      "failed": "Ihre Nachricht konnte gerade nicht gesendet werden. Versuchen Sie es gleich noch einmal.",
+      "limited": "Sie haben schon einige Nachrichten gesendet. Versuchen Sie es in einer Stunde wieder."
+    }
+  },
+  "import": {
+    "button": "Importieren",
+    "title": "Fehlercodes importieren",
+    "description": "Fügen Sie die eigenen Codes einer Marke hinzu oder mehr zu Codes, die wir haben. Eine Person bei Torqvoice prüft jeden Code, bevor eine Werkstatt ihn sieht.",
+    "yours": "Ihre Importe",
+    "codes": "{count, plural, one {# Code} other {# Codes}}",
+    "status": {
+      "published": "{count} veröffentlicht",
+      "pending": "{count} warten",
+      "rejected": "{count} nicht veröffentlicht"
+    },
+    "examples": {
+      "title": "Mit einem Beispiel beginnen",
+      "sheet": "Beispieltabelle",
+      "sheetHint": "Excel, eine Zeile pro Code",
+      "json": "Beispiel-JSON",
+      "jsonHint": "Jedes Feld benannt",
+      "columns": "Behalten Sie die Spaltennamen bei: Code, Title, Description, Common issues, How to fix, Good to know und Causes. Nur Code und Title sind nötig. Schreiben Sie jede Ursache in eine eigene Zeile, mit (high), (medium) oder (low) dahinter, wenn Sie es wissen.",
+      "csv": "Als CSV herunterladen"
+    },
+    "make": "Marke",
+    "pickMake": "Marke wählen",
+    "language": "Sprache des Textes",
+    "source": "Woher es stammt (optional)",
+    "sourcePlaceholder": "Link oder Buch",
+    "checking": "Ihre Codes werden gelesen und geprüft",
+    "removeFile": "Datei entfernen",
+    "drop": "Datei hier ablegen oder zum Auswählen klicken",
+    "dropTypes": ".xlsx, .csv oder .json, bis zu 2.000 Codes",
+    "fileProblems": {
+      "unreadable": "Die Datei konnte nicht gelesen werden. Speichern Sie sie erneut als .xlsx, .csv oder .json.",
+      "empty": "Die Datei enthält keine Codes.",
+      "no_code_column": "Keine Spalte Code gefunden. Beginnen Sie mit der Beispieltabelle.",
+      "too_many": "Die Datei ist zu groß: höchstens 2.000 Codes und 5 MB. Teilen Sie sie auf.",
+      "format": "Dieser Dateityp wird nicht unterstützt. Verwenden Sie .xlsx, .csv oder .json.",
+      "legacy_xls": "Alte .xls-Dateien werden nicht unterstützt. Speichern Sie sie in Excel als .xlsx."
+    },
+    "counts": {
+      "new": "{count} neu",
+      "change": "{count} ergänzen unsere",
+      "problem": "{count} müssen korrigiert werden",
+      "same": "{count} schon gleich"
+    },
+    "rows": "Codes in der Datei",
+    "row": "Zeile {n}",
+    "more": "und {count} weitere",
+    "problemsLeftOut": "Codes, die korrigiert werden müssen, werden weggelassen. Korrigieren Sie sie in der Datei und legen Sie sie erneut ab, oder senden Sie den Rest jetzt.",
+    "rights": "Ich darf das teilen. Es ist nicht aus einer kostenpflichtigen oder geschützten Quelle kopiert.",
+    "missing": {
+      "make": "Wählen Sie zuerst die Marke",
+      "file": "Fügen Sie Ihre Datei hinzu",
+      "nothing": "Nichts in der Datei ergänzt das, was wir haben",
+      "rights": "Setzen Sie den Haken, um zu senden"
+    },
+    "reviewHint": "Nichts wird veröffentlicht, bevor eine Person es geprüft hat.",
+    "cancel": "Abbrechen",
+    "send": "{count, plural, one {# Code zur Prüfung senden} other {# Codes zur Prüfung senden}}",
+    "sendNone": "Zur Prüfung senden",
+    "sent": "{count, plural, one {Gesendet. # Code wartet auf Prüfung.} other {Gesendet. # Codes warten auf Prüfung.}}",
+    "errors": {
+      "failed": "Ihre Datei konnte gerade nicht geprüft oder gesendet werden. Versuchen Sie es gleich noch einmal.",
+      "paused": "Das Senden von Codes ist gerade pausiert. Versuchen Sie es später.",
+      "nothing": "Nichts in der Datei ergänzt das, was wir haben.",
+      "demo": "Das Importieren von Codes ist in der Demo ausgeschaltet.",
+      "rights": "Setzen Sie den Haken, dass Sie das teilen dürfen.",
+      "too_large": "Die Datei ist zu groß: höchstens 2.000 Codes und 5 MB."
+    },
+    "problems": {
+      "code": "Kein Code, den {make} verwendet",
+      "duplicate": "Derselbe Code zweimal in der Datei",
+      "title": "Braucht einen Titel",
+      "empty": "Nichts dazu geschrieben",
+      "too_long": "Zu lang: ein Titel bis 200 Zeichen, jeder Text bis 4.000",
+      "likelihood": "Die Wahrscheinlichkeit muss low, medium oder high sein",
+      "meanings": "{make} verwendet diesen Code für mehr als eine Sache. Teilen Sie es stattdessen auf der Seite des Codes."
+    },
+    "state": {
+      "new": "Neu",
+      "change": "Ergänzt {what}"
+    },
+    "fields": {
+      "title": "Titel",
+      "description": "Bedeutung",
+      "common_issues": "häufige Probleme",
+      "resolution": "Behebung",
+      "advisory": "Gut zu wissen",
+      "causes": "Ursachen"
+    },
+    "addsCauses": "{count, plural, one {# Ursache} other {# Ursachen}}",
+    "searchMake": "Marken suchen",
+    "noMake": "Keine Marke gefunden",
+    "useMake": "{name} verwenden",
+    "groups": {
+      "cars": "Pkw und Transporter",
+      "marine": "Boote und Motoren"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marken mit eigener Bedeutung für {code}",
+    "thisCar": "Marke dieses Fahrzeugs"
   }
   }
 }
 }

+ 351 - 3
messages/en/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Manufacturer details",
     "manufacturer": "Manufacturer details",
     "related": "Together with other codes",
     "related": "Together with other codes",
     "diagnosis": "Diagnosis tip",
     "diagnosis": "Diagnosis tip",
+    "common_issues": "Common issues",
+    "resolution": "How to fix it",
+    "advisory": "Advice",
     "other": "Good to know"
     "other": "Good to know"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Model",
     "model": "Model",
     "year": "Year",
     "year": "Year",
     "vehicleHint": "Only make, model, year and engine are shared, never the plate, VIN, customer or your workshop's name.",
     "vehicleHint": "Only make, model, year and engine are shared, never the plate, VIN, customer or your workshop's name.",
+    "meaning": "Meaning",
+    "meaningHint": "This code means something else in other control units. Your fix is shared for this meaning only.",
     "vehicleHintMarine": "Only make, model, year and engine are shared, never the registration, hull number, customer or your workshop's name.",
     "vehicleHintMarine": "Only make, model, year and engine are shared, never the registration, hull number, customer or your workshop's name.",
     "send": "Share fix",
     "send": "Share fix",
     "sent": "Thanks. Your fix is waiting for review.",
     "sent": "Thanks. Your fix is waiting for review.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Add codes} one {Add # code} other {Add # codes}}",
     "add": "{count, plural, =0 {Add codes} one {Add # code} other {Add # codes}}",
     "added": "{count, plural, one {# fault code added} other {# fault codes added}}",
     "added": "{count, plural, one {# fault code added} other {# fault codes added}}",
     "skipped": "{count, plural, one {# code was already on this job} other {# codes were already on this job}}",
     "skipped": "{count, plural, one {# code was already on this job} other {# codes were already on this job}}",
-    "failed": "Could not add the codes"
+    "failed": "Could not add the codes",
+    "descriptionOem": "Type or paste codes from the scan tool, OBD codes like P0420 or BMW codes like 2A82. Each one becomes an observation on this job, with its meaning saved.",
+    "placeholderOem": "P0420, 2A82 stored"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Seen {date}",
     "seenBefore": "Seen {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Fault codes are new",
     "title": "Fault codes are new",
-    "body": "We are adding codes all the time, so not every code is here yet. For now this covers the standard OBD-II codes; manufacturer-specific codes are not included yet."
+    "body": "This covers the standard OBD-II codes. We're working on adding makes' own codes, and new codes arrive all the time. Know where to find a make's codes? Tell us with Help us improve.",
+    "bodyBmw": "This covers the standard OBD-II codes and BMW's own codes, which MINI and Rolls-Royce share. We're working on adding more makes, and new codes arrive all the time. Know where to find a make's codes? Tell us with Help us improve.",
+    "bodyMakes": "This covers the standard OBD-II codes and the own codes of {makes}. We're working on adding more makes, and new codes arrive all the time. Know where to find a make's codes? Tell us with Help us improve."
   },
   },
   "support": {
   "support": {
     "button": "Contact support",
     "button": "Contact support",
@@ -245,6 +254,345 @@
     "done": "When you are done, go back to the computer and share the fix.",
     "done": "When you are done, go back to the computer and share the fix.",
     "privacy": "Keep faces, number plates and customer details out of the picture.",
     "privacy": "Keep faces, number plates and customer details out of the picture.",
     "expired": "This code has run out. Show a new one on the computer.",
     "expired": "This code has run out. Show a new one on the computer.",
-    "invalid": "This code does not work. Show a new one on the computer."
+    "invalid": "This code does not work. Show a new one on the computer.",
+    "waveform": {
+      "title": "Photo of the scope screen",
+      "forCode": "for the waveform you are sharing on the computer",
+      "intro": "Take or choose one photo of the scope screen. It appears on the computer.",
+      "full": "A waveform takes one photo. Remove it on the computer to take another.",
+      "done": "Go back to the computer and share the waveform.",
+      "added": "Photo added"
+    }
+  },
+  "oem": {
+    "badge": "{make} code",
+    "summary": "{count, plural, one {# meaning} other {# meanings}}. A BMW code can mean something different in each control unit.",
+    "matches": "Matches this car",
+    "noMatch": "None of these meanings is listed for this car's engine. Check which control unit your scan tool read the code from.",
+    "inEnglish": "{make}'s English text: {text}",
+    "bmwText": "{make}'s text: {text}",
+    "units": "Control units",
+    "moreUnits": "+{count} more",
+    "otherChassis": "Other chassis",
+    "years": "{from} to {to}",
+    "yearsFrom": "from {from}",
+    "source": "Meanings come from BMW's own diagnostic texts, most of them in German.",
+    "notServed": "{make} codes are not available yet.",
+    "notFound": "{code} is not in {make}'s data.",
+    "whereUsed": "Where {make} uses it",
+    "models": "Models",
+    "engineList": "Engines",
+    "fewerUnits": "Show fewer",
+    "fitsThisCar": "This car",
+    "meanings": "Meanings",
+    "otherMeanings": "and {count, plural, one {# other meaning} other {# other meanings}}"
+  },
+  "research": {
+    "inviteTitle": "Research a fix online",
+    "invite": "We search manufacturer documents, service bulletins and workshop forums for {code}. Only a specific answer with sources is kept, and a person at Torqvoice checks it.",
+    "button": "Research a fix",
+    "working": "Researching {code}",
+    "steps": {
+      "bulletins": "Searching manufacturer documents and service bulletins",
+      "forums": "Reading workshop forums for confirmed fixes",
+      "agree": "Checking that the sources agree",
+      "writing": "Writing up the steps"
+    },
+    "workingHint": "This takes a minute or two. What is found stays with the code for every workshop.",
+    "runningTitle": "Being researched right now",
+    "runningHint": "Another workshop asked a moment ago. Check again in a minute.",
+    "checkAgain": "Check again",
+    "title": "Researched fix",
+    "pending": "Waiting for a person to check it",
+    "published": "Checked by Torqvoice",
+    "pendingHint": "Found by AI from the sources below. Check it against the car before replacing parts.",
+    "sources": "Sources",
+    "helpfulQuestion": "Did this fix it?",
+    "helpfulYes": "Yes, it fixed it",
+    "helpfulNo": "No, it did not",
+    "thanks": "Thanks, noted",
+    "missedTitle": "No reliable fix found online",
+    "missed": "Nothing specific to {code} came up, so nothing was saved. We've noted that workshops need this code.",
+    "searchedOn": "Searched {date}",
+    "shareInstead": "Share your fix",
+    "errors": {
+      "busy": "This code is being researched right now. Check again in a minute.",
+      "limited": "Your workshop has used its research for today. Try again tomorrow.",
+      "failed": "The research could not run right now. Try again later."
+    }
+  },
+  "waveforms": {
+    "title": "Waveforms",
+    "description": "Scope recordings from other workshops, known good and faulty, to hold against what you measure.",
+    "share": "Share a waveform",
+    "unavailable": "Waveforms are not available right now.",
+    "empty": "No waveforms for {code} yet",
+    "emptyHint": "Have a known good recording? Share it, and the next technician chasing this code has something to compare with.",
+    "filter": {
+      "label": "Show",
+      "all": "All",
+      "good": "Known good",
+      "faulty": "Faulty"
+    },
+    "unknownVehicle": "Vehicle not given",
+    "unknownVehicleMarine": "Vessel not given",
+    "status": {
+      "pending": "Waiting for review",
+      "published": "Published",
+      "rejected": "Not published"
+    },
+    "hasRecording": "PicoScope file to download",
+    "hasCsv": "CSV export to download",
+    "kind": {
+      "good": "Known good",
+      "faulty": "Faulty"
+    },
+    "condition": {
+      "key_on": "Key on, engine off",
+      "cranking": "Cranking",
+      "idle": "Idle",
+      "rpm_2500": "2500 rpm",
+      "driving": "Driving",
+      "drivingMarine": "Under way",
+      "under_load": "Under load"
+    },
+    "chart": {
+      "label": "Waveform of {channels} over {span}",
+      "perDivision": "{value}/div",
+      "noSample": "no sample",
+      "between": "{lo} to {hi}"
+    },
+    "view": {
+      "label": "Show",
+      "trace": "Waveform",
+      "picture": "Picture of the screen"
+    },
+    "sharedOn": "Shared {date}",
+    "reason": "Reason: {reason}",
+    "compare": {
+      "label": "Compare with",
+      "none": "Nothing",
+      "close": "Stop comparing"
+    },
+    "note": "Notes",
+    "writtenIn": "Written in {language}",
+    "files": {
+      "title": "Files",
+      "psdata": "PicoScope recording",
+      "csv": "CSV export",
+      "pssettings": "Scope settings",
+      "openHint": "Open the recording in PicoScope to zoom in, measure and see every channel."
+    },
+    "nothingToDraw": "Only the recording was shared. Download it to open it in PicoScope.",
+    "openPicture": "Open the picture full size",
+    "close": "Close",
+    "withdraw": "Withdraw",
+    "withdrawTitle": "Withdraw this waveform?",
+    "withdrawConfirm": "It is deleted with its files, and no other workshop will see it.",
+    "withdrawn": "Waveform withdrawn",
+    "withdrawFailed": "The waveform could not be withdrawn right now. Try again in a moment.",
+    "shareDialog": {
+      "title": "Share a waveform for {code}",
+      "description": "Other workshops see it once a person at Torqvoice has checked it.",
+      "meaningHint": "This code means something else in other control units. Your waveform is shared for this meaning only.",
+      "kind": "What does it show?",
+      "kindHint": {
+        "good": "How it looks on a healthy car",
+        "goodMarine": "How it looks on a healthy engine",
+        "faulty": "What it looked like with the fault",
+        "faultyMarine": "What it looked like with the fault"
+      },
+      "measured": "What did you measure?",
+      "measuredPlaceholder": "Crankshaft sensor and inlet camshaft",
+      "measurements": {
+        "crank": "Crankshaft sensor",
+        "camCrank": "Camshaft and crankshaft",
+        "injector": "Injector",
+        "ignition": "Ignition coil",
+        "compression": "Relative compression",
+        "lambda": "Lambda sensor",
+        "maf": "Mass air flow sensor",
+        "alternator": "Alternator ripple",
+        "canBus": "CAN bus",
+        "fuelPump": "Fuel pump current"
+      },
+      "condition": "How was the engine running? (optional)",
+      "files": "Files",
+      "filesHint": "Any one is enough. The PicoScope file lets others open it in PicoScope; a CSV export or a picture lets them see it right here.",
+      "phone": {
+        "button": "Photo from phone",
+        "title": "Take a photo of the scope screen",
+        "body": "Scan the code with your phone's camera and take one photo of the screen. It shows up here when it arrives.",
+        "received": "The photo from your phone has arrived"
+      },
+      "drop": "Drop your PicoScope file here, or click to choose",
+      "dropMore": "Add another file",
+      "dropTypes": ".psdata, .pssettings, a CSV export or a picture of the screen",
+      "role": {
+        "psdata": "PicoScope recording",
+        "csv": "CSV export",
+        "image": "Picture of the screen",
+        "pssettings": "Scope settings"
+      },
+      "csvNotScope": "This is not a scope export. Save it again from PicoScope as CSV.",
+      "remove": "Remove {name}",
+      "fromPhone": "From your phone",
+      "previewHint": "This is how other workshops will see it.",
+      "csvTip": "Tip: add a CSV export too, so others can see the waveform here without opening PicoScope. In PicoScope, use Save As and pick CSV.",
+      "note": "Notes (optional)",
+      "notePlaceholder": "Where you probed, the scale you used and what to look for. For example: back-probed pin 2 at the sensor plug, the gap for the missing teeth shows once per turn.",
+      "notePlaceholderMarine": "Where you probed, the scale you used and what to look for.",
+      "sending": "Sending {percent}%",
+      "send": "Share waveform",
+      "sent": "Thanks. Your waveform is waiting for review.",
+      "missing": {
+        "kind": "Pick known good or faulty",
+        "title": "Say what you measured",
+        "file": "Add a file or a picture",
+        "csv": "Remove the CSV that is not a scope export"
+      },
+      "notAScopeFile": "{name} is not a PicoScope file, a CSV export or a picture",
+      "fileTooBig": "{name} is larger than {size}",
+      "errors": {
+        "failed": "Your waveform could not be sent right now. Try again in a moment.",
+        "photo": "The picture could not be read. Try another one.",
+        "csv": "The CSV is not a scope export. Save it again from PicoScope as CSV.",
+        "too_large": "The files are too large.",
+        "no_file": "Add the recording, a CSV export or a picture.",
+        "paused": "Sharing waveforms is paused right now. Try again later.",
+        "demo": "Sharing waveforms is turned off on the demo."
+      }
+    }
+  },
+  "improve": {
+    "button": "Help us improve",
+    "title": "Help us improve fault codes",
+    "description": "Know where a make's own codes are published, spotted a mistake, or have an idea? Tell us. It goes straight to the Torqvoice team.",
+    "topic": "What is it about? (optional)",
+    "topics": {
+      "makes": "Codes we are missing",
+      "wrong": "Something is wrong",
+      "source": "A good source or link",
+      "idea": "An idea"
+    },
+    "message": "Your message",
+    "placeholders": {
+      "makes": "Where to find them, for example a link to the make's code list, and which models it covers.",
+      "wrong": "Which code, what it says and what it should say. A link to where you found it helps.",
+      "source": "Paste the link and say what is in it.",
+      "idea": "What would make fault codes more useful in your workshop? Links are welcome."
+    },
+    "links": "Links in your message",
+    "about": "About {code}",
+    "leaveOut": "Leave out",
+    "replyHint": "We reply to the email on your account.",
+    "cancel": "Cancel",
+    "send": "Send",
+    "sent": "Thanks. Your message is on its way to the Torqvoice team.",
+    "errors": {
+      "failed": "Your message could not be sent right now. Try again in a moment.",
+      "limited": "You have sent a few messages already. Try again in an hour."
+    }
+  },
+  "import": {
+    "button": "Import",
+    "title": "Import fault codes",
+    "description": "Add a make's own codes, or more about codes we have. A person at Torqvoice checks every code before any workshop sees it.",
+    "yours": "Your imports",
+    "codes": "{count, plural, one {# code} other {# codes}}",
+    "status": {
+      "published": "{count} published",
+      "pending": "{count} waiting",
+      "rejected": "{count} not published"
+    },
+    "examples": {
+      "title": "Start from an example",
+      "sheet": "Example spreadsheet",
+      "sheetHint": "Excel, one row per code",
+      "json": "Example JSON",
+      "jsonHint": "Every field named",
+      "columns": "Keep the column names: Code, Title, Description, Common issues, How to fix, Good to know and Causes. Only Code and Title are needed. Put each cause on its own line, with (high), (medium) or (low) after it if you know.",
+      "csv": "Download it as CSV"
+    },
+    "make": "Make",
+    "pickMake": "Pick a make",
+    "language": "Language of the text",
+    "source": "Where it comes from (optional)",
+    "sourcePlaceholder": "Link or book",
+    "checking": "Reading and checking your codes",
+    "removeFile": "Remove the file",
+    "drop": "Drop your file here, or click to choose",
+    "dropTypes": ".xlsx, .csv or .json, up to 2,000 codes",
+    "fileProblems": {
+      "unreadable": "The file could not be read. Save it again as .xlsx, .csv or .json.",
+      "empty": "The file has no codes.",
+      "no_code_column": "No Code column was found. Start from the example spreadsheet.",
+      "too_many": "The file is too large: at most 2,000 codes and 5 MB. Split it into parts.",
+      "format": "This file type is not supported. Use .xlsx, .csv or .json.",
+      "legacy_xls": "Old .xls files are not supported. Save it as .xlsx in Excel."
+    },
+    "counts": {
+      "new": "{count} new",
+      "change": "{count} add to ours",
+      "problem": "{count} need a fix",
+      "same": "{count} already the same"
+    },
+    "rows": "Codes in the file",
+    "row": "Row {n}",
+    "more": "and {count} more",
+    "problemsLeftOut": "Codes that need a fix are left out. Fix them in the file and drop it again, or send the rest now.",
+    "rights": "I may share this. It is not copied from a paid or protected source.",
+    "missing": {
+      "make": "Pick the make first",
+      "file": "Add your file",
+      "nothing": "Nothing in the file adds to what we have",
+      "rights": "Tick the box to send"
+    },
+    "reviewHint": "Nothing is published before a person has checked it.",
+    "cancel": "Cancel",
+    "send": "{count, plural, one {Send # code for review} other {Send # codes for review}}",
+    "sendNone": "Send for review",
+    "sent": "{count, plural, one {Sent. # code waits for review.} other {Sent. # codes wait for review.}}",
+    "errors": {
+      "failed": "Your file could not be checked or sent right now. Try again in a moment.",
+      "paused": "Sending codes is paused right now. Try again later.",
+      "nothing": "Nothing in the file adds to what we have.",
+      "demo": "Importing codes is turned off on the demo.",
+      "rights": "Tick the box to say you may share this.",
+      "too_large": "The file is too large: at most 2,000 codes and 5 MB."
+    },
+    "problems": {
+      "code": "Not a code {make} uses",
+      "duplicate": "The same code twice in the file",
+      "title": "Needs a title",
+      "empty": "Nothing written for it",
+      "too_long": "Too long: a title up to 200 characters, each text up to 4,000",
+      "likelihood": "Likelihood must be low, medium or high",
+      "meanings": "{make} uses this code for more than one thing. Share it on the code's page instead."
+    },
+    "state": {
+      "new": "New",
+      "change": "Adds {what}"
+    },
+    "fields": {
+      "title": "title",
+      "description": "what it means",
+      "common_issues": "common issues",
+      "resolution": "how to fix",
+      "advisory": "good to know",
+      "causes": "causes"
+    },
+    "addsCauses": "{count, plural, one {# cause} other {# causes}}",
+    "searchMake": "Search makes",
+    "noMake": "No make found",
+    "useMake": "Use {name}",
+    "groups": {
+      "cars": "Cars and vans",
+      "marine": "Boats and engines"
+    }
+  },
+  "makeCodes": {
+    "heading": "Makes with their own meaning for {code}",
+    "thisCar": "This car's make"
   }
   }
 }
 }

+ 351 - 3
messages/es/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Datos de los fabricantes",
     "manufacturer": "Datos de los fabricantes",
     "related": "Junto con otros códigos",
     "related": "Junto con otros códigos",
     "diagnosis": "Consejo de diagnóstico",
     "diagnosis": "Consejo de diagnóstico",
+    "common_issues": "Problemas frecuentes",
+    "resolution": "Solución",
+    "advisory": "Recomendación",
     "other": "Conviene saber"
     "other": "Conviene saber"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modelo",
     "model": "Modelo",
     "year": "Año",
     "year": "Año",
     "vehicleHint": "Solo se comparten la marca, el modelo, el año y el motor, nunca la matrícula, el VIN, el cliente ni el nombre de tu taller.",
     "vehicleHint": "Solo se comparten la marca, el modelo, el año y el motor, nunca la matrícula, el VIN, el cliente ni el nombre de tu taller.",
+    "meaning": "Significado",
+    "meaningHint": "Este código significa otra cosa en otras unidades de control. Tu solución se comparte solo para este significado.",
     "vehicleHintMarine": "Solo se comparten la marca, el modelo, el año y el motor, nunca la matrícula, el número de casco, el cliente ni el nombre de tu taller.",
     "vehicleHintMarine": "Solo se comparten la marca, el modelo, el año y el motor, nunca la matrícula, el número de casco, el cliente ni el nombre de tu taller.",
     "send": "Compartir solución",
     "send": "Compartir solución",
     "sent": "Gracias. Tu solución está pendiente de revisión.",
     "sent": "Gracias. Tu solución está pendiente de revisión.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Añadir códigos} one {Añadir # código} other {Añadir # códigos}}",
     "add": "{count, plural, =0 {Añadir códigos} one {Añadir # código} other {Añadir # códigos}}",
     "added": "{count, plural, one {# código de avería añadido} other {# códigos de avería añadidos}}",
     "added": "{count, plural, one {# código de avería añadido} other {# códigos de avería añadidos}}",
     "skipped": "{count, plural, one {# código ya estaba en este trabajo} other {# códigos ya estaban en este trabajo}}",
     "skipped": "{count, plural, one {# código ya estaba en este trabajo} other {# códigos ya estaban en este trabajo}}",
-    "failed": "No se pudieron añadir los códigos"
+    "failed": "No se pudieron añadir los códigos",
+    "descriptionOem": "Escribe o pega los códigos del equipo de diagnosis, códigos OBD como P0420 o códigos BMW como 2A82. Cada uno se convierte en una observación de este trabajo, con su significado guardado.",
+    "placeholderOem": "P0420, 2A82 almacenado"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Visto {date}",
     "seenBefore": "Visto {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Los códigos de avería son nuevos",
     "title": "Los códigos de avería son nuevos",
-    "body": "Añadimos códigos continuamente, así que todavía no están todos. Por ahora incluye los códigos OBD-II estándar; los códigos propios de cada fabricante aún no están incluidos."
+    "body": "Incluye los códigos OBD-II estándar. Estamos trabajando para añadir los códigos propios de cada marca, y llegan códigos nuevos continuamente. ¿Sabes dónde encontrar los códigos de una marca? Cuéntanoslo con «Ayúdanos a mejorar».",
+    "bodyBmw": "Incluye los códigos OBD-II estándar y los códigos propios de BMW, que comparten MINI y Rolls-Royce. Estamos trabajando para añadir más marcas, y llegan códigos nuevos continuamente. ¿Sabes dónde encontrar los códigos de una marca? Cuéntanoslo con «Ayúdanos a mejorar».",
+    "bodyMakes": "Incluye los códigos OBD-II estándar y los códigos propios de {makes}. Estamos trabajando para añadir más marcas, y llegan códigos nuevos continuamente. ¿Sabes dónde encontrar los códigos de una marca? Cuéntanoslo con «Ayúdanos a mejorar»."
   },
   },
   "support": {
   "support": {
     "button": "Contactar con soporte",
     "button": "Contactar con soporte",
@@ -245,6 +254,345 @@
     "done": "Cuando termines, vuelve al ordenador y comparte la solución.",
     "done": "Cuando termines, vuelve al ordenador y comparte la solución.",
     "privacy": "Deja fuera de la imagen caras, matrículas y datos de clientes.",
     "privacy": "Deja fuera de la imagen caras, matrículas y datos de clientes.",
     "expired": "Este código ha caducado. Muestra uno nuevo en el ordenador.",
     "expired": "Este código ha caducado. Muestra uno nuevo en el ordenador.",
-    "invalid": "Este código no funciona. Muestra uno nuevo en el ordenador."
+    "invalid": "Este código no funciona. Muestra uno nuevo en el ordenador.",
+    "waveform": {
+      "title": "Foto de la pantalla del osciloscopio",
+      "forCode": "para el oscilograma que compartes en el ordenador",
+      "intro": "Haz o elige una foto de la pantalla del osciloscopio. Aparece en el ordenador.",
+      "full": "Un oscilograma lleva una foto. Quítala en el ordenador para hacer otra.",
+      "done": "Vuelve al ordenador y comparte el oscilograma.",
+      "added": "Foto añadida"
+    }
+  },
+  "oem": {
+    "badge": "Código {make}",
+    "summary": "{count, plural, one {# significado} other {# significados}}. Un código BMW puede significar algo distinto en cada unidad de control.",
+    "matches": "Coincide con este coche",
+    "noMatch": "Ninguno de estos significados figura para el motor de este coche. Comprueba de qué unidad de control leyó el código tu equipo de diagnosis.",
+    "inEnglish": "Texto en inglés de {make}: {text}",
+    "bmwText": "Texto de {make}: {text}",
+    "units": "Unidades de control",
+    "moreUnits": "+{count} más",
+    "otherChassis": "Otros chasis",
+    "years": "{from} a {to}",
+    "yearsFrom": "desde {from}",
+    "source": "Los significados proceden de los textos de diagnosis de BMW, la mayoría en alemán.",
+    "notServed": "Los códigos {make} aún no están disponibles.",
+    "notFound": "{code} no está en los datos de {make}.",
+    "whereUsed": "Dónde lo usa {make}",
+    "models": "Modelos",
+    "engineList": "Motores",
+    "fewerUnits": "Mostrar menos",
+    "fitsThisCar": "Este vehículo",
+    "meanings": "Significados",
+    "otherMeanings": "y {count, plural, one {# significado más} other {# significados más}}"
+  },
+  "research": {
+    "inviteTitle": "Buscar una solución en línea",
+    "invite": "Buscamos {code} en documentación del fabricante, boletines técnicos y foros de talleres. Solo se guarda una respuesta concreta con fuentes, y una persona de Torqvoice la revisa.",
+    "button": "Buscar una solución",
+    "working": "Investigando {code}",
+    "steps": {
+      "bulletins": "Buscando en documentación del fabricante y boletines técnicos",
+      "forums": "Leyendo foros de talleres con soluciones confirmadas",
+      "agree": "Comprobando que las fuentes coinciden",
+      "writing": "Redactando los pasos"
+    },
+    "workingHint": "Tarda uno o dos minutos. Lo que se encuentre queda en el código para todos los talleres.",
+    "runningTitle": "Se está investigando ahora",
+    "runningHint": "Otro taller lo pidió hace un momento. Vuelve a mirar en un minuto.",
+    "checkAgain": "Volver a mirar",
+    "title": "Solución investigada",
+    "pending": "Pendiente de revisión por una persona",
+    "published": "Revisado por Torqvoice",
+    "pendingHint": "Encontrado por IA a partir de las fuentes de abajo. Compruébalo en el vehículo antes de cambiar piezas.",
+    "sources": "Fuentes",
+    "helpfulQuestion": "¿Te ayudó?",
+    "helpfulYes": "Sí, lo solucionó",
+    "helpfulNo": "No, no lo solucionó",
+    "thanks": "Gracias, anotado",
+    "missedTitle": "No se encontró una solución fiable en línea",
+    "missed": "No apareció nada concreto sobre {code}, así que no se guardó nada. Hemos anotado que los talleres necesitan este código.",
+    "searchedOn": "Buscado el {date}",
+    "shareInstead": "Comparte tu solución",
+    "errors": {
+      "busy": "Este código se está investigando ahora. Vuelve a mirar en un minuto.",
+      "limited": "Tu taller ya ha usado sus búsquedas de hoy. Inténtalo mañana.",
+      "failed": "La búsqueda no pudo hacerse ahora. Inténtalo más tarde."
+    }
+  },
+  "waveforms": {
+    "title": "Oscilogramas",
+    "description": "Capturas de osciloscopio de otros talleres, correctas y con fallo, para comparar con lo que mides.",
+    "share": "Compartir un oscilograma",
+    "unavailable": "Los oscilogramas no están disponibles ahora mismo.",
+    "empty": "Todavía no hay oscilogramas para {code}",
+    "emptyHint": "¿Tienes una captura correcta? Compártela y el próximo técnico que persiga este código tendrá con qué comparar.",
+    "filter": {
+      "label": "Mostrar",
+      "all": "Todos",
+      "good": "Correcto",
+      "faulty": "Con fallo"
+    },
+    "unknownVehicle": "Vehículo no indicado",
+    "unknownVehicleMarine": "Embarcación no indicada",
+    "status": {
+      "pending": "Pendiente de revisión",
+      "published": "Publicado",
+      "rejected": "No publicado"
+    },
+    "hasRecording": "Archivo de PicoScope para descargar",
+    "hasCsv": "Exportación CSV para descargar",
+    "kind": {
+      "good": "Correcto",
+      "faulty": "Con fallo"
+    },
+    "condition": {
+      "key_on": "Contacto puesto, motor parado",
+      "cranking": "Arranque",
+      "idle": "Ralentí",
+      "rpm_2500": "2500 rpm",
+      "driving": "Circulando",
+      "drivingMarine": "Navegando",
+      "under_load": "Con carga"
+    },
+    "chart": {
+      "label": "Oscilograma de {channels} durante {span}",
+      "perDivision": "{value}/div",
+      "noSample": "sin muestra",
+      "between": "{lo} a {hi}"
+    },
+    "view": {
+      "label": "Mostrar",
+      "trace": "Oscilograma",
+      "picture": "Foto de la pantalla"
+    },
+    "sharedOn": "Compartido el {date}",
+    "reason": "Motivo: {reason}",
+    "compare": {
+      "label": "Comparar con",
+      "none": "Nada",
+      "close": "Dejar de comparar"
+    },
+    "note": "Notas",
+    "writtenIn": "Escrito en {language}",
+    "files": {
+      "title": "Archivos",
+      "psdata": "Captura de PicoScope",
+      "csv": "Exportación CSV",
+      "pssettings": "Ajustes del osciloscopio",
+      "openHint": "Abre la captura en PicoScope para ampliar, medir y ver todos los canales."
+    },
+    "nothingToDraw": "Solo se compartió la captura. Descárgala para abrirla en PicoScope.",
+    "openPicture": "Abrir la foto a tamaño completo",
+    "close": "Cerrar",
+    "withdraw": "Retirar",
+    "withdrawTitle": "¿Retirar este oscilograma?",
+    "withdrawConfirm": "Se borra con sus archivos y ningún otro taller lo verá.",
+    "withdrawn": "Oscilograma retirado",
+    "withdrawFailed": "No se pudo retirar el oscilograma ahora mismo. Inténtalo de nuevo en un momento.",
+    "shareDialog": {
+      "title": "Compartir un oscilograma para {code}",
+      "description": "Otros talleres lo ven cuando una persona de Torqvoice lo ha revisado.",
+      "meaningHint": "Este código significa otra cosa en otras unidades de control. Tu oscilograma se comparte solo para este significado.",
+      "kind": "¿Qué muestra?",
+      "kindHint": {
+        "good": "Cómo se ve en un coche sano",
+        "goodMarine": "Cómo se ve en un motor sano",
+        "faulty": "Cómo se veía con el fallo",
+        "faultyMarine": "Cómo se veía con el fallo"
+      },
+      "measured": "¿Qué mediste?",
+      "measuredPlaceholder": "Sensor de cigüeñal y árbol de levas de admisión",
+      "measurements": {
+        "crank": "Sensor de cigüeñal",
+        "camCrank": "Árbol de levas y cigüeñal",
+        "injector": "Inyector",
+        "ignition": "Bobina de encendido",
+        "compression": "Compresión relativa",
+        "lambda": "Sonda lambda",
+        "maf": "Caudalímetro",
+        "alternator": "Rizado del alternador",
+        "canBus": "Bus CAN",
+        "fuelPump": "Corriente de la bomba de combustible"
+      },
+      "condition": "¿Cómo funcionaba el motor? (opcional)",
+      "files": "Archivos",
+      "filesHint": "Con uno basta. El archivo de PicoScope permite abrirlo en PicoScope; una exportación CSV o una foto permite verlo aquí mismo.",
+      "phone": {
+        "button": "Foto desde el móvil",
+        "title": "Haz una foto de la pantalla del osciloscopio",
+        "body": "Escanea el código con la cámara del móvil y haz una foto de la pantalla. Aparece aquí cuando llega.",
+        "received": "Ha llegado la foto de tu móvil"
+      },
+      "drop": "Suelta aquí tu archivo de PicoScope o haz clic para elegirlo",
+      "dropMore": "Añadir otro archivo",
+      "dropTypes": ".psdata, .pssettings, una exportación CSV o una foto de la pantalla",
+      "role": {
+        "psdata": "Captura de PicoScope",
+        "csv": "Exportación CSV",
+        "image": "Foto de la pantalla",
+        "pssettings": "Ajustes del osciloscopio"
+      },
+      "csvNotScope": "No es una exportación de osciloscopio. Guárdala de nuevo desde PicoScope como CSV.",
+      "remove": "Quitar {name}",
+      "fromPhone": "Desde tu móvil",
+      "previewHint": "Así lo verán otros talleres.",
+      "csvTip": "Consejo: añade también una exportación CSV para que otros vean el oscilograma aquí sin abrir PicoScope. En PicoScope, usa Guardar como y elige CSV.",
+      "note": "Notas (opcional)",
+      "notePlaceholder": "Dónde mediste, la escala que usaste y en qué fijarse. Por ejemplo: pin 2 por detrás del conector del sensor, el hueco de los dientes que faltan aparece una vez por vuelta.",
+      "notePlaceholderMarine": "Dónde mediste, la escala que usaste y en qué fijarse.",
+      "sending": "Enviando {percent} %",
+      "send": "Compartir oscilograma",
+      "sent": "Gracias. Tu oscilograma está pendiente de revisión.",
+      "missing": {
+        "kind": "Elige correcto o con fallo",
+        "title": "Indica qué mediste",
+        "file": "Añade un archivo o una foto",
+        "csv": "Quita el CSV que no es una exportación de osciloscopio"
+      },
+      "notAScopeFile": "{name} no es un archivo de PicoScope, una exportación CSV ni una foto",
+      "fileTooBig": "{name} ocupa más de {size}",
+      "errors": {
+        "failed": "No se pudo enviar tu oscilograma ahora mismo. Inténtalo de nuevo en un momento.",
+        "photo": "No se pudo leer la foto. Prueba con otra.",
+        "csv": "El CSV no es una exportación de osciloscopio. Guárdalo de nuevo desde PicoScope como CSV.",
+        "too_large": "Los archivos son demasiado grandes.",
+        "no_file": "Añade la captura, una exportación CSV o una foto.",
+        "paused": "Compartir oscilogramas está en pausa ahora mismo. Inténtalo más tarde.",
+        "demo": "Compartir oscilogramas está desactivado en la demo."
+      }
+    }
+  },
+  "improve": {
+    "button": "Ayúdanos a mejorar",
+    "title": "Ayúdanos a mejorar los códigos de avería",
+    "description": "¿Sabes dónde publica una marca sus propios códigos, has visto un error o tienes una idea? Cuéntanoslo. Llega directamente al equipo de Torqvoice.",
+    "topic": "¿De qué se trata? (opcional)",
+    "topics": {
+      "makes": "Códigos que faltan",
+      "wrong": "Algo está mal",
+      "source": "Una buena fuente o enlace",
+      "idea": "Una idea"
+    },
+    "message": "Tu mensaje",
+    "placeholders": {
+      "makes": "Dónde encontrarlos, por ejemplo un enlace a la lista de códigos de la marca, y qué modelos cubre.",
+      "wrong": "Qué código, qué dice y qué debería decir. Un enlace a donde lo encontraste ayuda.",
+      "source": "Pega el enlace y di qué contiene.",
+      "idea": "¿Qué haría más útiles los códigos de avería en tu taller? Los enlaces son bienvenidos."
+    },
+    "links": "Enlaces en tu mensaje",
+    "about": "Sobre {code}",
+    "leaveOut": "Quitar",
+    "replyHint": "Respondemos al correo de tu cuenta.",
+    "cancel": "Cancelar",
+    "send": "Enviar",
+    "sent": "Gracias. Tu mensaje va de camino al equipo de Torqvoice.",
+    "errors": {
+      "failed": "No se pudo enviar tu mensaje ahora mismo. Inténtalo de nuevo en un momento.",
+      "limited": "Ya has enviado varios mensajes. Inténtalo de nuevo dentro de una hora."
+    }
+  },
+  "import": {
+    "button": "Importar",
+    "title": "Importar códigos de avería",
+    "description": "Añade los códigos propios de una marca, o más sobre códigos que tenemos. Una persona de Torqvoice revisa cada código antes de que lo vea ningún taller.",
+    "yours": "Tus importaciones",
+    "codes": "{count, plural, one {# código} other {# códigos}}",
+    "status": {
+      "published": "{count} publicados",
+      "pending": "{count} pendientes",
+      "rejected": "{count} no publicados"
+    },
+    "examples": {
+      "title": "Empieza con un ejemplo",
+      "sheet": "Hoja de ejemplo",
+      "sheetHint": "Excel, una fila por código",
+      "json": "JSON de ejemplo",
+      "jsonHint": "Cada campo con su nombre",
+      "columns": "Mantén los nombres de columna: Code, Title, Description, Common issues, How to fix, Good to know y Causes. Solo hacen falta Code y Title. Pon cada causa en su propia línea, con (high), (medium) o (low) detrás si lo sabes.",
+      "csv": "Descárgala como CSV"
+    },
+    "make": "Marca",
+    "pickMake": "Elige una marca",
+    "language": "Idioma del texto",
+    "source": "De dónde viene (opcional)",
+    "sourcePlaceholder": "Enlace o libro",
+    "checking": "Leyendo y comprobando tus códigos",
+    "removeFile": "Quitar el archivo",
+    "drop": "Suelta aquí tu archivo, o haz clic para elegirlo",
+    "dropTypes": ".xlsx, .csv o .json, hasta 2.000 códigos",
+    "fileProblems": {
+      "unreadable": "No se pudo leer el archivo. Guárdalo de nuevo como .xlsx, .csv o .json.",
+      "empty": "El archivo no tiene códigos.",
+      "no_code_column": "No hay columna Code. Empieza con la hoja de ejemplo.",
+      "too_many": "El archivo es demasiado grande: como máximo 2.000 códigos y 5 MB. Divídelo en partes.",
+      "format": "Este tipo de archivo no es compatible. Usa .xlsx, .csv o .json.",
+      "legacy_xls": "Los archivos .xls antiguos no son compatibles. Guárdalo como .xlsx en Excel."
+    },
+    "counts": {
+      "new": "{count} nuevos",
+      "change": "{count} amplían los nuestros",
+      "problem": "{count} hay que corregir",
+      "same": "{count} ya iguales"
+    },
+    "rows": "Códigos del archivo",
+    "row": "Fila {n}",
+    "more": "y {count} más",
+    "problemsLeftOut": "Los códigos que hay que corregir se dejan fuera. Corrígelos en el archivo y suéltalo de nuevo, o envía el resto ahora.",
+    "rights": "Puedo compartir esto. No está copiado de una fuente de pago o protegida.",
+    "missing": {
+      "make": "Elige primero la marca",
+      "file": "Añade tu archivo",
+      "nothing": "Nada del archivo amplía lo que tenemos",
+      "rights": "Marca la casilla para enviar"
+    },
+    "reviewHint": "No se publica nada antes de que una persona lo haya revisado.",
+    "cancel": "Cancelar",
+    "send": "{count, plural, one {Enviar # código a revisión} other {Enviar # códigos a revisión}}",
+    "sendNone": "Enviar a revisión",
+    "sent": "{count, plural, one {Enviado. # código espera revisión.} other {Enviado. # códigos esperan revisión.}}",
+    "errors": {
+      "failed": "No se pudo comprobar ni enviar tu archivo ahora mismo. Inténtalo de nuevo en un momento.",
+      "paused": "El envío de códigos está en pausa ahora mismo. Inténtalo más tarde.",
+      "nothing": "Nada del archivo amplía lo que tenemos.",
+      "demo": "Importar códigos está desactivado en la demo.",
+      "rights": "Marca la casilla para indicar que puedes compartirlo.",
+      "too_large": "El archivo es demasiado grande: como máximo 2.000 códigos y 5 MB."
+    },
+    "problems": {
+      "code": "No es un código que use {make}",
+      "duplicate": "El mismo código dos veces en el archivo",
+      "title": "Necesita un título",
+      "empty": "No tiene nada escrito",
+      "too_long": "Demasiado largo: un título de hasta 200 caracteres, cada texto hasta 4.000",
+      "likelihood": "La probabilidad debe ser low, medium o high",
+      "meanings": "{make} usa este código para más de una cosa. Compártelo en la página del código."
+    },
+    "state": {
+      "new": "Nuevo",
+      "change": "Añade {what}"
+    },
+    "fields": {
+      "title": "título",
+      "description": "qué significa",
+      "common_issues": "problemas frecuentes",
+      "resolution": "cómo arreglarlo",
+      "advisory": "a tener en cuenta",
+      "causes": "causas"
+    },
+    "addsCauses": "{count, plural, one {# causa} other {# causas}}",
+    "searchMake": "Buscar marcas",
+    "noMake": "No se encontró ninguna marca",
+    "useMake": "Usar {name}",
+    "groups": {
+      "cars": "Coches y furgonetas",
+      "marine": "Barcos y motores"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marcas con su propio significado para {code}",
+    "thisCar": "Marca de este vehículo"
   }
   }
 }
 }

+ 351 - 3
messages/fr/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Données constructeurs",
     "manufacturer": "Données constructeurs",
     "related": "Avec d'autres codes",
     "related": "Avec d'autres codes",
     "diagnosis": "Conseil de diagnostic",
     "diagnosis": "Conseil de diagnostic",
+    "common_issues": "Problèmes fréquents",
+    "resolution": "Solution",
+    "advisory": "Recommandation",
     "other": "Bon à savoir"
     "other": "Bon à savoir"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modèle",
     "model": "Modèle",
     "year": "Année",
     "year": "Année",
     "vehicleHint": "Seuls la marque, le modèle, l'année et le moteur sont partagés, jamais la plaque, le VIN, le client ni le nom de votre atelier.",
     "vehicleHint": "Seuls la marque, le modèle, l'année et le moteur sont partagés, jamais la plaque, le VIN, le client ni le nom de votre atelier.",
+    "meaning": "Signification",
+    "meaningHint": "Ce code signifie autre chose dans d’autres calculateurs. Votre solution est partagée pour cette signification uniquement.",
     "vehicleHintMarine": "Seuls la marque, le modèle, l'année et le moteur sont partagés, jamais l'immatriculation, le numéro de coque, le client ni le nom de votre atelier.",
     "vehicleHintMarine": "Seuls la marque, le modèle, l'année et le moteur sont partagés, jamais l'immatriculation, le numéro de coque, le client ni le nom de votre atelier.",
     "send": "Partager la solution",
     "send": "Partager la solution",
     "sent": "Merci. Votre solution attend d'être vérifiée.",
     "sent": "Merci. Votre solution attend d'être vérifiée.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Ajouter des codes} one {Ajouter # code} other {Ajouter # codes}}",
     "add": "{count, plural, =0 {Ajouter des codes} one {Ajouter # code} other {Ajouter # codes}}",
     "added": "{count, plural, one {# code défaut ajouté} other {# codes défaut ajoutés}}",
     "added": "{count, plural, one {# code défaut ajouté} other {# codes défaut ajoutés}}",
     "skipped": "{count, plural, one {# code figurait déjà sur ce travail} other {# codes figuraient déjà sur ce travail}}",
     "skipped": "{count, plural, one {# code figurait déjà sur ce travail} other {# codes figuraient déjà sur ce travail}}",
-    "failed": "Impossible d'ajouter les codes"
+    "failed": "Impossible d'ajouter les codes",
+    "descriptionOem": "Saisissez ou collez les codes de l'outil de diagnostic, des codes OBD comme P0420 ou des codes BMW comme 2A82. Chacun devient une observation sur ce travail, avec sa signification enregistrée.",
+    "placeholderOem": "P0420, 2A82 mémorisé"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Vu {date}",
     "seenBefore": "Vu {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Les codes défaut sont nouveaux",
     "title": "Les codes défaut sont nouveaux",
-    "body": "Nous ajoutons des codes en permanence, donc tous ne sont pas encore là. Pour l’instant, ils couvrent les codes OBD-II standard ; les codes propres aux constructeurs ne sont pas encore inclus."
+    "body": "Cette liste couvre les codes OBD-II standard. Nous travaillons à ajouter les codes propres aux constructeurs, et de nouveaux codes arrivent en permanence. Vous savez où trouver les codes d'une marque ? Dites-le-nous avec « Aidez-nous à progresser ».",
+    "bodyBmw": "Cette liste couvre les codes OBD-II standard et les codes propres à BMW, partagés par MINI et Rolls-Royce. Nous travaillons à ajouter d'autres marques, et de nouveaux codes arrivent en permanence. Vous savez où trouver les codes d'une marque ? Dites-le-nous avec « Aidez-nous à progresser ».",
+    "bodyMakes": "Cette liste couvre les codes OBD-II standard et les codes propres à {makes}. Nous travaillons à ajouter d'autres marques, et de nouveaux codes arrivent en permanence. Vous savez où trouver les codes d'une marque ? Dites-le-nous avec « Aidez-nous à progresser »."
   },
   },
   "support": {
   "support": {
     "button": "Contacter le support",
     "button": "Contacter le support",
@@ -245,6 +254,345 @@
     "done": "Quand vous avez fini, revenez à l’ordinateur et partagez la solution.",
     "done": "Quand vous avez fini, revenez à l’ordinateur et partagez la solution.",
     "privacy": "Laissez hors de l’image les visages, les plaques et les données des clients.",
     "privacy": "Laissez hors de l’image les visages, les plaques et les données des clients.",
     "expired": "Ce code a expiré. Affichez-en un nouveau sur l’ordinateur.",
     "expired": "Ce code a expiré. Affichez-en un nouveau sur l’ordinateur.",
-    "invalid": "Ce code ne fonctionne pas. Affichez-en un nouveau sur l’ordinateur."
+    "invalid": "Ce code ne fonctionne pas. Affichez-en un nouveau sur l’ordinateur.",
+    "waveform": {
+      "title": "Photo de l'écran de l'oscilloscope",
+      "forCode": "pour l'oscillogramme que vous partagez sur l'ordinateur",
+      "intro": "Prenez ou choisissez une photo de l'écran de l'oscilloscope. Elle apparaît sur l'ordinateur.",
+      "full": "Un oscillogramme a une seule photo. Retirez-la sur l'ordinateur pour en prendre une autre.",
+      "done": "Retournez à l'ordinateur et partagez l'oscillogramme.",
+      "added": "Photo ajoutée"
+    }
+  },
+  "oem": {
+    "badge": "Code {make}",
+    "summary": "{count, plural, one {# signification} other {# significations}}. Un code BMW peut signifier autre chose dans chaque calculateur.",
+    "matches": "Correspond à ce véhicule",
+    "noMatch": "Aucune de ces significations n'est indiquée pour le moteur de ce véhicule. Vérifiez de quel calculateur votre outil de diagnostic a lu le code.",
+    "inEnglish": "Texte anglais de {make} : {text}",
+    "bmwText": "Texte de {make} : {text}",
+    "units": "Calculateurs",
+    "moreUnits": "+{count} autres",
+    "otherChassis": "Autres châssis",
+    "years": "{from} à {to}",
+    "yearsFrom": "depuis {from}",
+    "source": "Les significations viennent des textes de diagnostic de BMW, la plupart en allemand.",
+    "notServed": "Les codes {make} ne sont pas encore disponibles.",
+    "notFound": "{code} n'est pas dans les données {make}.",
+    "whereUsed": "Où {make} l’utilise",
+    "models": "Modèles",
+    "engineList": "Moteurs",
+    "fewerUnits": "Afficher moins",
+    "fitsThisCar": "Ce véhicule",
+    "meanings": "Significations",
+    "otherMeanings": "et {count, plural, one {# autre signification} other {# autres significations}}"
+  },
+  "research": {
+    "inviteTitle": "Rechercher une solution en ligne",
+    "invite": "Nous cherchons {code} dans la documentation constructeur, les notes techniques et les forums d’ateliers. Seule une réponse précise et sourcée est gardée, et une personne de Torqvoice la vérifie.",
+    "button": "Rechercher une solution",
+    "working": "Recherche sur {code}",
+    "steps": {
+      "bulletins": "Recherche dans la documentation constructeur et les notes techniques",
+      "forums": "Lecture des forums d’ateliers aux solutions confirmées",
+      "agree": "Vérification de l’accord entre les sources",
+      "writing": "Rédaction des étapes"
+    },
+    "workingHint": "Cela prend une à deux minutes. Ce qui est trouvé reste avec le code pour tous les ateliers.",
+    "runningTitle": "Recherche en cours",
+    "runningHint": "Un autre atelier vient de la demander. Revenez dans une minute.",
+    "checkAgain": "Vérifier à nouveau",
+    "title": "Solution recherchée",
+    "pending": "En attente de vérification par une personne",
+    "published": "Vérifié par Torqvoice",
+    "pendingHint": "Trouvé par l’IA à partir des sources ci-dessous. Vérifiez sur le véhicule avant de remplacer des pièces.",
+    "sources": "Sources",
+    "helpfulQuestion": "Est-ce que ça a résolu le problème ?",
+    "helpfulYes": "Oui, ça l’a résolu",
+    "helpfulNo": "Non",
+    "thanks": "Merci, c’est noté",
+    "missedTitle": "Aucune solution fiable trouvée en ligne",
+    "missed": "Rien de précis sur {code} n’est ressorti, donc rien n’a été enregistré. Nous avons noté que les ateliers ont besoin de ce code.",
+    "searchedOn": "Recherché le {date}",
+    "shareInstead": "Partager votre solution",
+    "errors": {
+      "busy": "Ce code est en cours de recherche. Revenez dans une minute.",
+      "limited": "Votre atelier a utilisé ses recherches du jour. Réessayez demain.",
+      "failed": "La recherche n’a pas pu se faire pour le moment. Réessayez plus tard."
+    }
+  },
+  "waveforms": {
+    "title": "Oscillogrammes",
+    "description": "Relevés d'oscilloscope d'autres ateliers, bons et défaillants, à comparer avec votre mesure.",
+    "share": "Partager un oscillogramme",
+    "unavailable": "Les oscillogrammes ne sont pas disponibles pour le moment.",
+    "empty": "Pas encore d'oscillogramme pour {code}",
+    "emptyHint": "Vous avez un relevé de référence ? Partagez-le, et le prochain technicien sur ce code aura de quoi comparer.",
+    "filter": {
+      "label": "Afficher",
+      "all": "Tous",
+      "good": "Référence",
+      "faulty": "Défaillant"
+    },
+    "unknownVehicle": "Véhicule non indiqué",
+    "unknownVehicleMarine": "Bateau non indiqué",
+    "status": {
+      "pending": "En attente de vérification",
+      "published": "Publié",
+      "rejected": "Non publié"
+    },
+    "hasRecording": "Fichier PicoScope à télécharger",
+    "hasCsv": "Export CSV à télécharger",
+    "kind": {
+      "good": "Référence",
+      "faulty": "Défaillant"
+    },
+    "condition": {
+      "key_on": "Contact mis, moteur arrêté",
+      "cranking": "Démarreur",
+      "idle": "Ralenti",
+      "rpm_2500": "2500 tr/min",
+      "driving": "En roulant",
+      "drivingMarine": "En navigation",
+      "under_load": "En charge"
+    },
+    "chart": {
+      "label": "Oscillogramme de {channels} sur {span}",
+      "perDivision": "{value}/div",
+      "noSample": "pas de mesure",
+      "between": "{lo} à {hi}"
+    },
+    "view": {
+      "label": "Afficher",
+      "trace": "Oscillogramme",
+      "picture": "Photo de l'écran"
+    },
+    "sharedOn": "Partagé le {date}",
+    "reason": "Motif : {reason}",
+    "compare": {
+      "label": "Comparer avec",
+      "none": "Rien",
+      "close": "Arrêter la comparaison"
+    },
+    "note": "Remarques",
+    "writtenIn": "Écrit en {language}",
+    "files": {
+      "title": "Fichiers",
+      "psdata": "Enregistrement PicoScope",
+      "csv": "Export CSV",
+      "pssettings": "Réglages de l'oscilloscope",
+      "openHint": "Ouvrez l'enregistrement dans PicoScope pour zoomer, mesurer et voir chaque voie."
+    },
+    "nothingToDraw": "Seul l'enregistrement a été partagé. Téléchargez-le pour l'ouvrir dans PicoScope.",
+    "openPicture": "Ouvrir la photo en grand",
+    "close": "Fermer",
+    "withdraw": "Retirer",
+    "withdrawTitle": "Retirer cet oscillogramme ?",
+    "withdrawConfirm": "Il est supprimé avec ses fichiers, et aucun autre atelier ne le verra.",
+    "withdrawn": "Oscillogramme retiré",
+    "withdrawFailed": "L'oscillogramme n'a pas pu être retiré pour le moment. Réessayez dans un instant.",
+    "shareDialog": {
+      "title": "Partager un oscillogramme pour {code}",
+      "description": "Les autres ateliers le voient dès qu'une personne chez Torqvoice l'a vérifié.",
+      "meaningHint": "Ce code a un autre sens dans d'autres calculateurs. Votre oscillogramme est partagé pour ce sens uniquement.",
+      "kind": "Que montre-t-il ?",
+      "kindHint": {
+        "good": "L'allure sur un véhicule sain",
+        "goodMarine": "L'allure sur un moteur sain",
+        "faulty": "L'allure avec la panne",
+        "faultyMarine": "L'allure avec la panne"
+      },
+      "measured": "Qu'avez-vous mesuré ?",
+      "measuredPlaceholder": "Capteur de vilebrequin et arbre à cames d'admission",
+      "measurements": {
+        "crank": "Capteur de vilebrequin",
+        "camCrank": "Arbre à cames et vilebrequin",
+        "injector": "Injecteur",
+        "ignition": "Bobine d'allumage",
+        "compression": "Compression relative",
+        "lambda": "Sonde lambda",
+        "maf": "Débitmètre d'air",
+        "alternator": "Ondulation de l'alternateur",
+        "canBus": "Bus CAN",
+        "fuelPump": "Courant de la pompe à carburant"
+      },
+      "condition": "Comment tournait le moteur ? (facultatif)",
+      "files": "Fichiers",
+      "filesHint": "Un seul suffit. Le fichier PicoScope permet de l'ouvrir dans PicoScope ; un export CSV ou une photo permet de le voir ici.",
+      "phone": {
+        "button": "Photo depuis le téléphone",
+        "title": "Photographiez l'écran de l'oscilloscope",
+        "body": "Scannez le code avec l'appareil photo du téléphone et prenez une photo de l'écran. Elle s'affiche ici dès qu'elle arrive.",
+        "received": "La photo de votre téléphone est arrivée"
+      },
+      "drop": "Déposez votre fichier PicoScope ici, ou cliquez pour choisir",
+      "dropMore": "Ajouter un autre fichier",
+      "dropTypes": ".psdata, .pssettings, un export CSV ou une photo de l'écran",
+      "role": {
+        "psdata": "Enregistrement PicoScope",
+        "csv": "Export CSV",
+        "image": "Photo de l'écran",
+        "pssettings": "Réglages de l'oscilloscope"
+      },
+      "csvNotScope": "Ce n'est pas un export d'oscilloscope. Enregistrez-le à nouveau depuis PicoScope en CSV.",
+      "remove": "Retirer {name}",
+      "fromPhone": "Depuis votre téléphone",
+      "previewHint": "Voici comment les autres ateliers le verront.",
+      "csvTip": "Astuce : ajoutez aussi un export CSV pour que les autres voient l'oscillogramme ici sans ouvrir PicoScope. Dans PicoScope, utilisez Enregistrer sous et choisissez CSV.",
+      "note": "Remarques (facultatif)",
+      "notePlaceholder": "Où vous avez mesuré, le calibre utilisé et ce qu'il faut regarder. Par exemple : broche 2 piquée à l'arrière du connecteur du capteur, le trou des dents manquantes apparaît une fois par tour.",
+      "notePlaceholderMarine": "Où vous avez mesuré, le calibre utilisé et ce qu'il faut regarder.",
+      "sending": "Envoi {percent} %",
+      "send": "Partager l'oscillogramme",
+      "sent": "Merci. Votre oscillogramme est en attente de vérification.",
+      "missing": {
+        "kind": "Choisissez référence ou défaillant",
+        "title": "Indiquez ce que vous avez mesuré",
+        "file": "Ajoutez un fichier ou une photo",
+        "csv": "Retirez le CSV qui n'est pas un export d'oscilloscope"
+      },
+      "notAScopeFile": "{name} n'est ni un fichier PicoScope, ni un export CSV, ni une photo",
+      "fileTooBig": "{name} dépasse {size}",
+      "errors": {
+        "failed": "Votre oscillogramme n'a pas pu être envoyé pour le moment. Réessayez dans un instant.",
+        "photo": "La photo n'a pas pu être lue. Essayez-en une autre.",
+        "csv": "Le CSV n'est pas un export d'oscilloscope. Enregistrez-le à nouveau depuis PicoScope en CSV.",
+        "too_large": "Les fichiers sont trop volumineux.",
+        "no_file": "Ajoutez l'enregistrement, un export CSV ou une photo.",
+        "paused": "Le partage d'oscillogrammes est en pause pour le moment. Réessayez plus tard.",
+        "demo": "Le partage d'oscillogrammes est désactivé sur la démo."
+      }
+    }
+  },
+  "improve": {
+    "button": "Aidez-nous à progresser",
+    "title": "Aidez-nous à améliorer les codes défaut",
+    "description": "Vous savez où une marque publie ses propres codes, vous avez repéré une erreur ou vous avez une idée ? Dites-le-nous. Le message va directement à l'équipe Torqvoice.",
+    "topic": "De quoi s'agit-il ? (facultatif)",
+    "topics": {
+      "makes": "Codes qui manquent",
+      "wrong": "Quelque chose est faux",
+      "source": "Une bonne source ou un lien",
+      "idea": "Une idée"
+    },
+    "message": "Votre message",
+    "placeholders": {
+      "makes": "Où les trouver, par exemple un lien vers la liste des codes de la marque, et quels modèles elle couvre.",
+      "wrong": "Quel code, ce qu'il indique et ce qu'il devrait indiquer. Un lien vers votre source aide.",
+      "source": "Collez le lien et dites ce qu'il contient.",
+      "idea": "Qu'est-ce qui rendrait les codes défaut plus utiles dans votre atelier ? Les liens sont les bienvenus."
+    },
+    "links": "Liens dans votre message",
+    "about": "À propos de {code}",
+    "leaveOut": "Retirer",
+    "replyHint": "Nous répondons à l'e-mail de votre compte.",
+    "cancel": "Annuler",
+    "send": "Envoyer",
+    "sent": "Merci. Votre message est en route vers l'équipe Torqvoice.",
+    "errors": {
+      "failed": "Votre message n'a pas pu être envoyé pour le moment. Réessayez dans un instant.",
+      "limited": "Vous avez déjà envoyé plusieurs messages. Réessayez dans une heure."
+    }
+  },
+  "import": {
+    "button": "Importer",
+    "title": "Importer des codes défaut",
+    "description": "Ajoutez les codes propres à une marque, ou plus d'informations sur des codes que nous avons. Une personne chez Torqvoice vérifie chaque code avant qu'un atelier ne le voie.",
+    "yours": "Vos imports",
+    "codes": "{count, plural, one {# code} other {# codes}}",
+    "status": {
+      "published": "{count} publiés",
+      "pending": "{count} en attente",
+      "rejected": "{count} non publiés"
+    },
+    "examples": {
+      "title": "Partir d'un exemple",
+      "sheet": "Tableur d'exemple",
+      "sheetHint": "Excel, une ligne par code",
+      "json": "JSON d'exemple",
+      "jsonHint": "Chaque champ nommé",
+      "columns": "Gardez les noms de colonnes : Code, Title, Description, Common issues, How to fix, Good to know et Causes. Seuls Code et Title sont nécessaires. Mettez chaque cause sur sa propre ligne, suivie de (high), (medium) ou (low) si vous le savez.",
+      "csv": "Le télécharger en CSV"
+    },
+    "make": "Marque",
+    "pickMake": "Choisir une marque",
+    "language": "Langue du texte",
+    "source": "D'où cela vient (facultatif)",
+    "sourcePlaceholder": "Lien ou livre",
+    "checking": "Lecture et vérification de vos codes",
+    "removeFile": "Retirer le fichier",
+    "drop": "Déposez votre fichier ici, ou cliquez pour choisir",
+    "dropTypes": ".xlsx, .csv ou .json, jusqu'à 2 000 codes",
+    "fileProblems": {
+      "unreadable": "Le fichier n'a pas pu être lu. Enregistrez-le à nouveau en .xlsx, .csv ou .json.",
+      "empty": "Le fichier ne contient aucun code.",
+      "no_code_column": "Aucune colonne Code trouvée. Partez du tableur d'exemple.",
+      "too_many": "Le fichier est trop volumineux : 2 000 codes et 5 Mo au plus. Découpez-le.",
+      "format": "Ce type de fichier n'est pas pris en charge. Utilisez .xlsx, .csv ou .json.",
+      "legacy_xls": "Les anciens fichiers .xls ne sont pas pris en charge. Enregistrez-le en .xlsx dans Excel."
+    },
+    "counts": {
+      "new": "{count} nouveaux",
+      "change": "{count} complètent les nôtres",
+      "problem": "{count} à corriger",
+      "same": "{count} déjà identiques"
+    },
+    "rows": "Codes du fichier",
+    "row": "Ligne {n}",
+    "more": "et {count} de plus",
+    "problemsLeftOut": "Les codes à corriger sont laissés de côté. Corrigez-les dans le fichier et déposez-le à nouveau, ou envoyez le reste maintenant.",
+    "rights": "J'ai le droit de partager ceci. Ce n'est pas copié d'une source payante ou protégée.",
+    "missing": {
+      "make": "Choisissez d'abord la marque",
+      "file": "Ajoutez votre fichier",
+      "nothing": "Rien dans le fichier ne complète ce que nous avons",
+      "rights": "Cochez la case pour envoyer"
+    },
+    "reviewHint": "Rien n'est publié avant qu'une personne l'ait vérifié.",
+    "cancel": "Annuler",
+    "send": "{count, plural, one {Envoyer # code pour vérification} other {Envoyer # codes pour vérification}}",
+    "sendNone": "Envoyer pour vérification",
+    "sent": "{count, plural, one {Envoyé. # code attend la vérification.} other {Envoyé. # codes attendent la vérification.}}",
+    "errors": {
+      "failed": "Votre fichier n'a pas pu être vérifié ou envoyé pour le moment. Réessayez dans un instant.",
+      "paused": "L'envoi de codes est en pause pour le moment. Réessayez plus tard.",
+      "nothing": "Rien dans le fichier ne complète ce que nous avons.",
+      "demo": "L'import de codes est désactivé sur la démo.",
+      "rights": "Cochez la case pour indiquer que vous pouvez partager ceci.",
+      "too_large": "Le fichier est trop volumineux : 2 000 codes et 5 Mo au plus."
+    },
+    "problems": {
+      "code": "Ce n'est pas un code utilisé par {make}",
+      "duplicate": "Le même code deux fois dans le fichier",
+      "title": "Il faut un titre",
+      "empty": "Rien n'est écrit pour ce code",
+      "too_long": "Trop long : un titre jusqu'à 200 caractères, chaque texte jusqu'à 4 000",
+      "likelihood": "La probabilité doit être low, medium ou high",
+      "meanings": "{make} utilise ce code pour plusieurs choses. Partagez-le plutôt sur la page du code."
+    },
+    "state": {
+      "new": "Nouveau",
+      "change": "Ajoute {what}"
+    },
+    "fields": {
+      "title": "le titre",
+      "description": "la signification",
+      "common_issues": "les problèmes fréquents",
+      "resolution": "la réparation",
+      "advisory": "bon à savoir",
+      "causes": "les causes"
+    },
+    "addsCauses": "{count, plural, one {# cause} other {# causes}}",
+    "searchMake": "Rechercher une marque",
+    "noMake": "Aucune marque trouvée",
+    "useMake": "Utiliser {name}",
+    "groups": {
+      "cars": "Voitures et utilitaires",
+      "marine": "Bateaux et moteurs"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marques avec leur propre signification pour {code}",
+    "thisCar": "Marque de ce véhicule"
   }
   }
 }
 }

+ 351 - 3
messages/it/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Dati dei costruttori",
     "manufacturer": "Dati dei costruttori",
     "related": "Insieme ad altri codici",
     "related": "Insieme ad altri codici",
     "diagnosis": "Consiglio di diagnosi",
     "diagnosis": "Consiglio di diagnosi",
+    "common_issues": "Problemi frequenti",
+    "resolution": "Soluzione",
+    "advisory": "Raccomandazione",
     "other": "Buono a sapersi"
     "other": "Buono a sapersi"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modello",
     "model": "Modello",
     "year": "Anno",
     "year": "Anno",
     "vehicleHint": "Si condividono solo marca, modello, anno e motore, mai la targa, il VIN, il cliente o il nome della tua officina.",
     "vehicleHint": "Si condividono solo marca, modello, anno e motore, mai la targa, il VIN, il cliente o il nome della tua officina.",
+    "meaning": "Significato",
+    "meaningHint": "Questo codice significa altro in altre centraline. La tua soluzione viene condivisa solo per questo significato.",
     "vehicleHintMarine": "Si condividono solo marca, modello, anno e motore, mai l'immatricolazione, il numero di scafo, il cliente o il nome della tua officina.",
     "vehicleHintMarine": "Si condividono solo marca, modello, anno e motore, mai l'immatricolazione, il numero di scafo, il cliente o il nome della tua officina.",
     "send": "Condividi soluzione",
     "send": "Condividi soluzione",
     "sent": "Grazie. La tua soluzione è in attesa di revisione.",
     "sent": "Grazie. La tua soluzione è in attesa di revisione.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Aggiungi codici} one {Aggiungi # codice} other {Aggiungi # codici}}",
     "add": "{count, plural, =0 {Aggiungi codici} one {Aggiungi # codice} other {Aggiungi # codici}}",
     "added": "{count, plural, one {# codice di errore aggiunto} other {# codici di errore aggiunti}}",
     "added": "{count, plural, one {# codice di errore aggiunto} other {# codici di errore aggiunti}}",
     "skipped": "{count, plural, one {# codice era già su questo lavoro} other {# codici erano già su questo lavoro}}",
     "skipped": "{count, plural, one {# codice era già su questo lavoro} other {# codici erano già su questo lavoro}}",
-    "failed": "Impossibile aggiungere i codici"
+    "failed": "Impossibile aggiungere i codici",
+    "descriptionOem": "Digita o incolla i codici dallo strumento di diagnosi, codici OBD come P0420 o codici BMW come 2A82. Ognuno diventa un’osservazione su questo lavoro, con il significato salvato.",
+    "placeholderOem": "P0420, 2A82 memorizzato"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Visto {date}",
     "seenBefore": "Visto {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "I codici guasto sono nuovi",
     "title": "I codici guasto sono nuovi",
-    "body": "Aggiungiamo codici continuamente, quindi non tutti sono ancora qui. Per ora sono inclusi i codici OBD-II standard; i codici specifici dei costruttori non sono ancora inclusi."
+    "body": "Sono inclusi i codici OBD-II standard. Stiamo lavorando per aggiungere i codici propri delle marche, e arrivano continuamente codici nuovi. Sai dove trovare i codici di una marca? Diccelo con «Aiutaci a migliorare».",
+    "bodyBmw": "Sono inclusi i codici OBD-II standard e i codici propri di BMW, condivisi da MINI e Rolls-Royce. Stiamo lavorando per aggiungere altre marche, e arrivano continuamente codici nuovi. Sai dove trovare i codici di una marca? Diccelo con «Aiutaci a migliorare».",
+    "bodyMakes": "Sono inclusi i codici OBD-II standard e i codici propri di {makes}. Stiamo lavorando per aggiungere altre marche, e arrivano continuamente codici nuovi. Sai dove trovare i codici di una marca? Diccelo con «Aiutaci a migliorare»."
   },
   },
   "support": {
   "support": {
     "button": "Contatta il supporto",
     "button": "Contatta il supporto",
@@ -245,6 +254,345 @@
     "done": "Quando hai finito, torna al computer e condividi la soluzione.",
     "done": "Quando hai finito, torna al computer e condividi la soluzione.",
     "privacy": "Tieni fuori dall’inquadratura volti, targhe e dati dei clienti.",
     "privacy": "Tieni fuori dall’inquadratura volti, targhe e dati dei clienti.",
     "expired": "Questo codice è scaduto. Mostrane uno nuovo al computer.",
     "expired": "Questo codice è scaduto. Mostrane uno nuovo al computer.",
-    "invalid": "Questo codice non funziona. Mostrane uno nuovo al computer."
+    "invalid": "Questo codice non funziona. Mostrane uno nuovo al computer.",
+    "waveform": {
+      "title": "Foto dello schermo dell'oscilloscopio",
+      "forCode": "per l'oscillogramma che stai condividendo sul computer",
+      "intro": "Scatta o scegli una foto dello schermo dell'oscilloscopio. Compare sul computer.",
+      "full": "Un oscillogramma ha una sola foto. Rimuovila sul computer per scattarne un'altra.",
+      "done": "Torna al computer e condividi l'oscillogramma.",
+      "added": "Foto aggiunta"
+    }
+  },
+  "oem": {
+    "badge": "Codice {make}",
+    "summary": "{count, plural, one {# significato} other {# significati}}. Un codice BMW può significare cose diverse in ogni centralina.",
+    "matches": "Corrisponde a questa auto",
+    "noMatch": "Nessuno di questi significati è indicato per il motore di questa auto. Controlla da quale centralina lo strumento di diagnosi ha letto il codice.",
+    "inEnglish": "Testo inglese di {make}: {text}",
+    "bmwText": "Testo di {make}: {text}",
+    "units": "Centraline",
+    "moreUnits": "+{count} altre",
+    "otherChassis": "Altri telai",
+    "years": "dal {from} al {to}",
+    "yearsFrom": "dal {from}",
+    "source": "I significati provengono dai testi di diagnosi di BMW, per lo più in tedesco.",
+    "notServed": "I codici {make} non sono ancora disponibili.",
+    "notFound": "{code} non è nei dati {make}.",
+    "whereUsed": "Dove lo usa {make}",
+    "models": "Modelli",
+    "engineList": "Motori",
+    "fewerUnits": "Mostra meno",
+    "fitsThisCar": "Questo veicolo",
+    "meanings": "Significati",
+    "otherMeanings": "e {count, plural, one {# altro significato} other {altri # significati}}"
+  },
+  "research": {
+    "inviteTitle": "Cerca una soluzione online",
+    "invite": "Cerchiamo {code} nella documentazione del costruttore, nei bollettini tecnici e nei forum delle officine. Si conserva solo una risposta precisa con fonti, e una persona di Torqvoice la controlla.",
+    "button": "Cerca una soluzione",
+    "working": "Ricerca su {code}",
+    "steps": {
+      "bulletins": "Ricerca in documentazione del costruttore e bollettini tecnici",
+      "forums": "Lettura dei forum delle officine con soluzioni confermate",
+      "agree": "Verifica che le fonti concordino",
+      "writing": "Stesura dei passaggi"
+    },
+    "workingHint": "Ci vogliono uno o due minuti. Quello che si trova resta con il codice per tutte le officine.",
+    "runningTitle": "Ricerca in corso",
+    "runningHint": "Un’altra officina l’ha chiesta poco fa. Ricontrolla tra un minuto.",
+    "checkAgain": "Ricontrolla",
+    "title": "Soluzione ricercata",
+    "pending": "In attesa del controllo di una persona",
+    "published": "Controllato da Torqvoice",
+    "pendingHint": "Trovato dall’IA dalle fonti qui sotto. Verificalo sul veicolo prima di sostituire parti.",
+    "sources": "Fonti",
+    "helpfulQuestion": "Ha risolto?",
+    "helpfulYes": "Sì, ha risolto",
+    "helpfulNo": "No, non ha risolto",
+    "thanks": "Grazie, annotato",
+    "missedTitle": "Nessuna soluzione affidabile trovata online",
+    "missed": "Non è emerso nulla di preciso su {code}, quindi non è stato salvato nulla. Abbiamo annotato che le officine hanno bisogno di questo codice.",
+    "searchedOn": "Cercato il {date}",
+    "shareInstead": "Condividi la tua soluzione",
+    "errors": {
+      "busy": "Questo codice è in fase di ricerca. Ricontrolla tra un minuto.",
+      "limited": "La tua officina ha usato le ricerche di oggi. Riprova domani.",
+      "failed": "La ricerca non è riuscita ora. Riprova più tardi."
+    }
+  },
+  "waveforms": {
+    "title": "Oscillogrammi",
+    "description": "Registrazioni dell'oscilloscopio di altre officine, corrette e difettose, da confrontare con la tua misura.",
+    "share": "Condividi un oscillogramma",
+    "unavailable": "Gli oscillogrammi non sono disponibili al momento.",
+    "empty": "Ancora nessun oscillogramma per {code}",
+    "emptyHint": "Hai una registrazione corretta? Condividila, e il prossimo tecnico alle prese con questo codice avrà qualcosa da confrontare.",
+    "filter": {
+      "label": "Mostra",
+      "all": "Tutti",
+      "good": "Corretto",
+      "faulty": "Difettoso"
+    },
+    "unknownVehicle": "Veicolo non indicato",
+    "unknownVehicleMarine": "Imbarcazione non indicata",
+    "status": {
+      "pending": "In attesa di verifica",
+      "published": "Pubblicato",
+      "rejected": "Non pubblicato"
+    },
+    "hasRecording": "File PicoScope da scaricare",
+    "hasCsv": "Esportazione CSV da scaricare",
+    "kind": {
+      "good": "Corretto",
+      "faulty": "Difettoso"
+    },
+    "condition": {
+      "key_on": "Quadro acceso, motore spento",
+      "cranking": "Avviamento",
+      "idle": "Minimo",
+      "rpm_2500": "2500 giri/min",
+      "driving": "In marcia",
+      "drivingMarine": "In navigazione",
+      "under_load": "Sotto carico"
+    },
+    "chart": {
+      "label": "Oscillogramma di {channels} su {span}",
+      "perDivision": "{value}/div",
+      "noSample": "nessun campione",
+      "between": "da {lo} a {hi}"
+    },
+    "view": {
+      "label": "Mostra",
+      "trace": "Oscillogramma",
+      "picture": "Foto dello schermo"
+    },
+    "sharedOn": "Condiviso il {date}",
+    "reason": "Motivo: {reason}",
+    "compare": {
+      "label": "Confronta con",
+      "none": "Niente",
+      "close": "Smetti di confrontare"
+    },
+    "note": "Note",
+    "writtenIn": "Scritto in {language}",
+    "files": {
+      "title": "File",
+      "psdata": "Registrazione PicoScope",
+      "csv": "Esportazione CSV",
+      "pssettings": "Impostazioni dell'oscilloscopio",
+      "openHint": "Apri la registrazione in PicoScope per ingrandire, misurare e vedere ogni canale."
+    },
+    "nothingToDraw": "È stata condivisa solo la registrazione. Scaricala per aprirla in PicoScope.",
+    "openPicture": "Apri la foto a grandezza piena",
+    "close": "Chiudi",
+    "withdraw": "Ritira",
+    "withdrawTitle": "Ritirare questo oscillogramma?",
+    "withdrawConfirm": "Viene eliminato con i suoi file e nessun'altra officina lo vedrà.",
+    "withdrawn": "Oscillogramma ritirato",
+    "withdrawFailed": "Non è stato possibile ritirare l'oscillogramma ora. Riprova tra un momento.",
+    "shareDialog": {
+      "title": "Condividi un oscillogramma per {code}",
+      "description": "Le altre officine lo vedono quando una persona di Torqvoice lo ha verificato.",
+      "meaningHint": "Questo codice ha un altro significato in altre centraline. Il tuo oscillogramma viene condiviso solo per questo significato.",
+      "kind": "Cosa mostra?",
+      "kindHint": {
+        "good": "Com'è su un'auto sana",
+        "goodMarine": "Com'è su un motore sano",
+        "faulty": "Com'era con il guasto",
+        "faultyMarine": "Com'era con il guasto"
+      },
+      "measured": "Cosa hai misurato?",
+      "measuredPlaceholder": "Sensore albero motore e albero a camme di aspirazione",
+      "measurements": {
+        "crank": "Sensore albero motore",
+        "camCrank": "Albero a camme e albero motore",
+        "injector": "Iniettore",
+        "ignition": "Bobina di accensione",
+        "compression": "Compressione relativa",
+        "lambda": "Sonda lambda",
+        "maf": "Debimetro",
+        "alternator": "Ondulazione dell'alternatore",
+        "canBus": "Bus CAN",
+        "fuelPump": "Corrente della pompa carburante"
+      },
+      "condition": "Come girava il motore? (facoltativo)",
+      "files": "File",
+      "filesHint": "Ne basta uno. Il file PicoScope permette di aprirlo in PicoScope; un'esportazione CSV o una foto permette di vederlo qui.",
+      "phone": {
+        "button": "Foto dal telefono",
+        "title": "Fotografa lo schermo dell'oscilloscopio",
+        "body": "Inquadra il codice con la fotocamera del telefono e scatta una foto dello schermo. Compare qui appena arriva.",
+        "received": "La foto dal tuo telefono è arrivata"
+      },
+      "drop": "Trascina qui il file PicoScope, o fai clic per sceglierlo",
+      "dropMore": "Aggiungi un altro file",
+      "dropTypes": ".psdata, .pssettings, un'esportazione CSV o una foto dello schermo",
+      "role": {
+        "psdata": "Registrazione PicoScope",
+        "csv": "Esportazione CSV",
+        "image": "Foto dello schermo",
+        "pssettings": "Impostazioni dell'oscilloscopio"
+      },
+      "csvNotScope": "Non è un'esportazione dell'oscilloscopio. Salvala di nuovo da PicoScope come CSV.",
+      "remove": "Rimuovi {name}",
+      "fromPhone": "Dal tuo telefono",
+      "previewHint": "Ecco come lo vedranno le altre officine.",
+      "csvTip": "Suggerimento: aggiungi anche un'esportazione CSV, così gli altri vedono l'oscillogramma qui senza aprire PicoScope. In PicoScope, usa Salva con nome e scegli CSV.",
+      "note": "Note (facoltative)",
+      "notePlaceholder": "Dove hai misurato, la scala usata e cosa guardare. Per esempio: pin 2 dal retro del connettore del sensore, il vuoto dei denti mancanti compare una volta per giro.",
+      "notePlaceholderMarine": "Dove hai misurato, la scala usata e cosa guardare.",
+      "sending": "Invio {percent}%",
+      "send": "Condividi oscillogramma",
+      "sent": "Grazie. Il tuo oscillogramma è in attesa di verifica.",
+      "missing": {
+        "kind": "Scegli corretto o difettoso",
+        "title": "Indica cosa hai misurato",
+        "file": "Aggiungi un file o una foto",
+        "csv": "Rimuovi il CSV che non è un'esportazione dell'oscilloscopio"
+      },
+      "notAScopeFile": "{name} non è un file PicoScope, un'esportazione CSV o una foto",
+      "fileTooBig": "{name} supera {size}",
+      "errors": {
+        "failed": "Non è stato possibile inviare il tuo oscillogramma ora. Riprova tra un momento.",
+        "photo": "Non è stato possibile leggere la foto. Prova con un'altra.",
+        "csv": "Il CSV non è un'esportazione dell'oscilloscopio. Salvalo di nuovo da PicoScope come CSV.",
+        "too_large": "I file sono troppo grandi.",
+        "no_file": "Aggiungi la registrazione, un'esportazione CSV o una foto.",
+        "paused": "La condivisione degli oscillogrammi è in pausa. Riprova più tardi.",
+        "demo": "La condivisione degli oscillogrammi è disattivata nella demo."
+      }
+    }
+  },
+  "improve": {
+    "button": "Aiutaci a migliorare",
+    "title": "Aiutaci a migliorare i codici guasto",
+    "description": "Sai dove una marca pubblica i suoi codici, hai notato un errore o hai un'idea? Scrivici. Arriva direttamente al team Torqvoice.",
+    "topic": "Di cosa si tratta? (facoltativo)",
+    "topics": {
+      "makes": "Codici che mancano",
+      "wrong": "Qualcosa non va",
+      "source": "Una buona fonte o un link",
+      "idea": "Un'idea"
+    },
+    "message": "Il tuo messaggio",
+    "placeholders": {
+      "makes": "Dove trovarli, per esempio un link all'elenco codici della marca, e quali modelli copre.",
+      "wrong": "Quale codice, cosa dice e cosa dovrebbe dire. Un link a dove l'hai trovato aiuta.",
+      "source": "Incolla il link e di' cosa contiene.",
+      "idea": "Cosa renderebbe i codici guasto più utili nella tua officina? I link sono benvenuti."
+    },
+    "links": "Link nel tuo messaggio",
+    "about": "Su {code}",
+    "leaveOut": "Togli",
+    "replyHint": "Rispondiamo all'email del tuo account.",
+    "cancel": "Annulla",
+    "send": "Invia",
+    "sent": "Grazie. Il tuo messaggio sta arrivando al team Torqvoice.",
+    "errors": {
+      "failed": "Non è stato possibile inviare il tuo messaggio ora. Riprova tra un momento.",
+      "limited": "Hai già inviato alcuni messaggi. Riprova tra un'ora."
+    }
+  },
+  "import": {
+    "button": "Importa",
+    "title": "Importa codici guasto",
+    "description": "Aggiungi i codici propri di una marca, o altro sui codici che abbiamo. Una persona di Torqvoice controlla ogni codice prima che un'officina lo veda.",
+    "yours": "Le tue importazioni",
+    "codes": "{count, plural, one {# codice} other {# codici}}",
+    "status": {
+      "published": "{count} pubblicati",
+      "pending": "{count} in attesa",
+      "rejected": "{count} non pubblicati"
+    },
+    "examples": {
+      "title": "Parti da un esempio",
+      "sheet": "Foglio di esempio",
+      "sheetHint": "Excel, una riga per codice",
+      "json": "JSON di esempio",
+      "jsonHint": "Ogni campo con il suo nome",
+      "columns": "Mantieni i nomi delle colonne: Code, Title, Description, Common issues, How to fix, Good to know e Causes. Servono solo Code e Title. Metti ogni causa su una riga, con (high), (medium) o (low) dopo se lo sai.",
+      "csv": "Scaricalo in CSV"
+    },
+    "make": "Marca",
+    "pickMake": "Scegli una marca",
+    "language": "Lingua del testo",
+    "source": "Da dove viene (facoltativo)",
+    "sourcePlaceholder": "Link o libro",
+    "checking": "Lettura e controllo dei tuoi codici",
+    "removeFile": "Rimuovi il file",
+    "drop": "Trascina qui il file, o fai clic per sceglierlo",
+    "dropTypes": ".xlsx, .csv o .json, fino a 2.000 codici",
+    "fileProblems": {
+      "unreadable": "Non è stato possibile leggere il file. Salvalo di nuovo come .xlsx, .csv o .json.",
+      "empty": "Il file non contiene codici.",
+      "no_code_column": "Nessuna colonna Code trovata. Parti dal foglio di esempio.",
+      "too_many": "Il file è troppo grande: al massimo 2.000 codici e 5 MB. Dividilo in parti.",
+      "format": "Questo tipo di file non è supportato. Usa .xlsx, .csv o .json.",
+      "legacy_xls": "I vecchi file .xls non sono supportati. Salvalo come .xlsx in Excel."
+    },
+    "counts": {
+      "new": "{count} nuovi",
+      "change": "{count} ampliano i nostri",
+      "problem": "{count} da correggere",
+      "same": "{count} già uguali"
+    },
+    "rows": "Codici nel file",
+    "row": "Riga {n}",
+    "more": "e altri {count}",
+    "problemsLeftOut": "I codici da correggere vengono esclusi. Correggili nel file e trascinalo di nuovo, o invia ora il resto.",
+    "rights": "Posso condividerlo. Non è copiato da una fonte a pagamento o protetta.",
+    "missing": {
+      "make": "Scegli prima la marca",
+      "file": "Aggiungi il tuo file",
+      "nothing": "Niente nel file amplia ciò che abbiamo",
+      "rights": "Spunta la casella per inviare"
+    },
+    "reviewHint": "Niente viene pubblicato prima che una persona lo abbia controllato.",
+    "cancel": "Annulla",
+    "send": "{count, plural, one {Invia # codice per la verifica} other {Invia # codici per la verifica}}",
+    "sendNone": "Invia per la verifica",
+    "sent": "{count, plural, one {Inviato. # codice attende la verifica.} other {Inviato. # codici attendono la verifica.}}",
+    "errors": {
+      "failed": "Non è stato possibile controllare o inviare il file ora. Riprova tra un momento.",
+      "paused": "L'invio di codici è in pausa. Riprova più tardi.",
+      "nothing": "Niente nel file amplia ciò che abbiamo.",
+      "demo": "L'importazione di codici è disattivata nella demo.",
+      "rights": "Spunta la casella per dire che puoi condividerlo.",
+      "too_large": "Il file è troppo grande: al massimo 2.000 codici e 5 MB."
+    },
+    "problems": {
+      "code": "Non è un codice usato da {make}",
+      "duplicate": "Lo stesso codice due volte nel file",
+      "title": "Serve un titolo",
+      "empty": "Non c'è niente di scritto",
+      "too_long": "Troppo lungo: un titolo fino a 200 caratteri, ogni testo fino a 4.000",
+      "likelihood": "La probabilità deve essere low, medium o high",
+      "meanings": "{make} usa questo codice per più di una cosa. Condividilo invece sulla pagina del codice."
+    },
+    "state": {
+      "new": "Nuovo",
+      "change": "Aggiunge {what}"
+    },
+    "fields": {
+      "title": "titolo",
+      "description": "significato",
+      "common_issues": "problemi frequenti",
+      "resolution": "come ripararlo",
+      "advisory": "buono a sapersi",
+      "causes": "cause"
+    },
+    "addsCauses": "{count, plural, one {# causa} other {# cause}}",
+    "searchMake": "Cerca marche",
+    "noMake": "Nessuna marca trovata",
+    "useMake": "Usa {name}",
+    "groups": {
+      "cars": "Auto e furgoni",
+      "marine": "Barche e motori"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marche con un proprio significato per {code}",
+    "thisCar": "Marca di questo veicolo"
   }
   }
 }
 }

+ 351 - 3
messages/lt/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Gamintojų duomenys",
     "manufacturer": "Gamintojų duomenys",
     "related": "Kartu su kitais kodais",
     "related": "Kartu su kitais kodais",
     "diagnosis": "Diagnostikos patarimas",
     "diagnosis": "Diagnostikos patarimas",
+    "common_issues": "Dažnos problemos",
+    "resolution": "Sprendimas",
+    "advisory": "Rekomendacija",
     "other": "Verta žinoti"
     "other": "Verta žinoti"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modelis",
     "model": "Modelis",
     "year": "Metai",
     "year": "Metai",
     "vehicleHint": "Dalijamasi tik marke, modeliu, metais ir varikliu, niekada valstybiniu numeriu, VIN, klientu ar jūsų serviso pavadinimu.",
     "vehicleHint": "Dalijamasi tik marke, modeliu, metais ir varikliu, niekada valstybiniu numeriu, VIN, klientu ar jūsų serviso pavadinimu.",
+    "meaning": "Reikšmė",
+    "meaningHint": "Kituose valdymo blokuose šis kodas reiškia ką kita. Jūsų sprendimu dalijamasi tik šiai reikšmei.",
     "vehicleHintMarine": "Dalijamasi tik marke, modeliu, metais ir varikliu, niekada registracijos numeriu, korpuso numeriu, klientu ar jūsų serviso pavadinimu.",
     "vehicleHintMarine": "Dalijamasi tik marke, modeliu, metais ir varikliu, niekada registracijos numeriu, korpuso numeriu, klientu ar jūsų serviso pavadinimu.",
     "send": "Pasidalyti sprendimu",
     "send": "Pasidalyti sprendimu",
     "sent": "Ačiū. Jūsų sprendimas laukia peržiūros.",
     "sent": "Ačiū. Jūsų sprendimas laukia peržiūros.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Pridėti kodus} one {Pridėti # kodą} few {Pridėti # kodus} many {Pridėti # kodo} other {Pridėti # kodų}}",
     "add": "{count, plural, =0 {Pridėti kodus} one {Pridėti # kodą} few {Pridėti # kodus} many {Pridėti # kodo} other {Pridėti # kodų}}",
     "added": "{count, plural, one {Pridėtas # gedimo kodas} few {Pridėti # gedimų kodai} many {Pridėta # gedimo kodo} other {Pridėta # gedimų kodų}}",
     "added": "{count, plural, one {Pridėtas # gedimo kodas} few {Pridėti # gedimų kodai} many {Pridėta # gedimo kodo} other {Pridėta # gedimų kodų}}",
     "skipped": "{count, plural, one {# kodas jau buvo šiame darbe} few {# kodai jau buvo šiame darbe} many {# kodo jau buvo šiame darbe} other {# kodų jau buvo šiame darbe}}",
     "skipped": "{count, plural, one {# kodas jau buvo šiame darbe} few {# kodai jau buvo šiame darbe} many {# kodo jau buvo šiame darbe} other {# kodų jau buvo šiame darbe}}",
-    "failed": "Nepavyko pridėti kodų"
+    "failed": "Nepavyko pridėti kodų",
+    "descriptionOem": "Įveskite arba įklijuokite kodus iš diagnostikos įrangos: OBD kodus, pvz., P0420, arba BMW kodus, pvz., 2A82. Kiekvienas tampa šio darbo pastaba su išsaugota reikšme.",
+    "placeholderOem": "P0420, 2A82 išsaugotas"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Matytas {date}",
     "seenBefore": "Matytas {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Gedimų kodai yra nauji",
     "title": "Gedimų kodai yra nauji",
-    "body": "Kodus pildome nuolat, todėl dar ne visi kodai čia yra. Kol kas yra standartiniai OBD-II kodai; gamintojų kodų dar nėra."
+    "body": "Yra standartiniai OBD-II kodai. Dirbame, kad pridėtume ir gamintojų savus kodus, o nauji kodai atsiranda nuolat. Žinote, kur rasti gamintojo kodus? Praneškite mums per „Padėkite mums tobulėti“.",
+    "bodyBmw": "Yra standartiniai OBD-II kodai ir BMW kodai, kuriuos naudoja ir MINI bei Rolls-Royce. Dirbame, kad pridėtume daugiau gamintojų, o nauji kodai atsiranda nuolat. Žinote, kur rasti gamintojo kodus? Praneškite mums per „Padėkite mums tobulėti“.",
+    "bodyMakes": "Yra standartiniai OBD-II kodai ir šių gamintojų kodai: {makes}. Dirbame, kad pridėtume daugiau gamintojų, o nauji kodai atsiranda nuolat. Žinote, kur rasti gamintojo kodus? Praneškite mums per „Padėkite mums tobulėti“."
   },
   },
   "support": {
   "support": {
     "button": "Susisiekti su pagalba",
     "button": "Susisiekti su pagalba",
@@ -245,6 +254,345 @@
     "done": "Baigę grįžkite prie kompiuterio ir pasidalykite sprendimu.",
     "done": "Baigę grįžkite prie kompiuterio ir pasidalykite sprendimu.",
     "privacy": "Į kadrą neįtraukite veidų, numerių ir klientų duomenų.",
     "privacy": "Į kadrą neįtraukite veidų, numerių ir klientų duomenų.",
     "expired": "Šio kodo galiojimas baigėsi. Kompiuteryje parodykite naują.",
     "expired": "Šio kodo galiojimas baigėsi. Kompiuteryje parodykite naują.",
-    "invalid": "Šis kodas neveikia. Kompiuteryje parodykite naują."
+    "invalid": "Šis kodas neveikia. Kompiuteryje parodykite naują.",
+    "waveform": {
+      "title": "Oscilografo ekrano nuotrauka",
+      "forCode": "oscilogramai, kuria dalinatės kompiuteryje",
+      "intro": "Padarykite arba pasirinkite vieną oscilografo ekrano nuotrauką. Ji atsiras kompiuteryje.",
+      "full": "Oscilograma turi vieną nuotrauką. Pašalinkite ją kompiuteryje, kad padarytumėte kitą.",
+      "done": "Grįžkite prie kompiuterio ir pasidalinkite oscilograma.",
+      "added": "Nuotrauka pridėta"
+    }
+  },
+  "oem": {
+    "badge": "{make} kodas",
+    "summary": "{count, plural, one {# reikšmė} few {# reikšmės} other {# reikšmių}}. BMW kodas kiekviename valdymo bloke gali reikšti ką kita.",
+    "matches": "Tinka šiam automobiliui",
+    "noMatch": "Nė viena iš šių reikšmių nenurodyta šio automobilio varikliui. Patikrinkite, iš kurio valdymo bloko diagnostikos įranga nuskaitė kodą.",
+    "inEnglish": "{make} tekstas anglų kalba: {text}",
+    "bmwText": "{make} tekstas: {text}",
+    "units": "Valdymo blokai",
+    "moreUnits": "dar {count}",
+    "otherChassis": "Kiti kėbulai",
+    "years": "{from}–{to}",
+    "yearsFrom": "nuo {from}",
+    "source": "Reikšmės paimtos iš BMW diagnostikos tekstų, dauguma jų vokiečių kalba.",
+    "notServed": "{make} kodų dar nėra.",
+    "notFound": "{code} nėra {make} duomenyse.",
+    "whereUsed": "Kur {make} jį naudoja",
+    "models": "Modeliai",
+    "engineList": "Varikliai",
+    "fewerUnits": "Rodyti mažiau",
+    "fitsThisCar": "Šis automobilis",
+    "meanings": "Reikšmės",
+    "otherMeanings": "ir dar {count, plural, one {# reikšmė} few {# reikšmės} other {# reikšmių}}"
+  },
+  "research": {
+    "inviteTitle": "Ieškoti sprendimo internete",
+    "invite": "Ieškome {code} gamintojo dokumentuose, techniniuose biuleteniuose ir servisų forumuose. Išsaugomas tik konkretus atsakymas su šaltiniais, o Torqvoice darbuotojas jį patikrina.",
+    "button": "Ieškoti sprendimo",
+    "working": "Ieškoma informacijos apie {code}",
+    "steps": {
+      "bulletins": "Ieškoma gamintojo dokumentuose ir techniniuose biuleteniuose",
+      "forums": "Skaitomi servisų forumai su patvirtintais sprendimais",
+      "agree": "Tikrinama, ar šaltiniai sutampa",
+      "writing": "Aprašomi žingsniai"
+    },
+    "workingHint": "Tai užtrunka minutę ar dvi. Kas randama, lieka prie kodo visiems servisams.",
+    "runningTitle": "Šiuo metu ieškoma",
+    "runningHint": "Kitas servisas ką tik paprašė. Patikrinkite po minutės.",
+    "checkAgain": "Patikrinti dar kartą",
+    "title": "Rastas sprendimas",
+    "pending": "Laukia žmogaus patikros",
+    "published": "Patikrinta Torqvoice",
+    "pendingHint": "Rasta DI iš toliau nurodytų šaltinių. Prieš keisdami dalis patikrinkite automobilyje.",
+    "sources": "Šaltiniai",
+    "helpfulQuestion": "Ar tai padėjo?",
+    "helpfulYes": "Taip, padėjo",
+    "helpfulNo": "Ne, nepadėjo",
+    "thanks": "Ačiū, pažymėta",
+    "missedTitle": "Patikimo sprendimo internete nerasta",
+    "missed": "Apie {code} nieko konkretaus nerasta, todėl nieko neišsaugota. Pažymėjome, kad servisams reikia šio kodo.",
+    "searchedOn": "Ieškota {date}",
+    "shareInstead": "Pasidalykite savo sprendimu",
+    "errors": {
+      "busy": "Šio kodo šiuo metu ieškoma. Patikrinkite po minutės.",
+      "limited": "Jūsų servisas šiandien jau išnaudojo paieškas. Bandykite rytoj.",
+      "failed": "Paieška šiuo metu nepavyko. Bandykite vėliau."
+    }
+  },
+  "waveforms": {
+    "title": "Oscilogramos",
+    "description": "Kitų servisų oscilografo įrašai, tvarkingi ir su gedimu, palyginti su tuo, ką matuojate.",
+    "share": "Dalintis oscilograma",
+    "unavailable": "Oscilogramos šiuo metu nepasiekiamos.",
+    "empty": "Kol kas nėra oscilogramų kodui {code}",
+    "emptyHint": "Turite tvarkingos sistemos įrašą? Pasidalinkite, ir kitas meistras, ieškantis šio kodo, turės su kuo palyginti.",
+    "filter": {
+      "label": "Rodyti",
+      "all": "Visos",
+      "good": "Tvarkinga",
+      "faulty": "Su gedimu"
+    },
+    "unknownVehicle": "Transporto priemonė nenurodyta",
+    "unknownVehicleMarine": "Laivas nenurodytas",
+    "status": {
+      "pending": "Laukia peržiūros",
+      "published": "Paskelbta",
+      "rejected": "Nepaskelbta"
+    },
+    "hasRecording": "PicoScope failas atsisiųsti",
+    "hasCsv": "CSV eksportas atsisiųsti",
+    "kind": {
+      "good": "Tvarkinga",
+      "faulty": "Su gedimu"
+    },
+    "condition": {
+      "key_on": "Uždegimas įjungtas, variklis išjungtas",
+      "cranking": "Užvedimas",
+      "idle": "Laisva eiga",
+      "rpm_2500": "2500 aps./min",
+      "driving": "Važiuojant",
+      "drivingMarine": "Plaukiant",
+      "under_load": "Su apkrova"
+    },
+    "chart": {
+      "label": "{channels} oscilograma per {span}",
+      "perDivision": "{value}/padal.",
+      "noSample": "nėra reikšmės",
+      "between": "nuo {lo} iki {hi}"
+    },
+    "view": {
+      "label": "Rodyti",
+      "trace": "Oscilograma",
+      "picture": "Ekrano nuotrauka"
+    },
+    "sharedOn": "Pasidalinta {date}",
+    "reason": "Priežastis: {reason}",
+    "compare": {
+      "label": "Palyginti su",
+      "none": "Niekuo",
+      "close": "Baigti lyginti"
+    },
+    "note": "Pastabos",
+    "writtenIn": "Parašyta kalba: {language}",
+    "files": {
+      "title": "Failai",
+      "psdata": "PicoScope įrašas",
+      "csv": "CSV eksportas",
+      "pssettings": "Oscilografo nustatymai",
+      "openHint": "Atidarykite įrašą PicoScope programoje, kad galėtumėte priartinti, matuoti ir matyti kiekvieną kanalą."
+    },
+    "nothingToDraw": "Pasidalinta tik įrašu. Atsisiųskite jį ir atidarykite PicoScope programoje.",
+    "openPicture": "Atidaryti nuotrauką visu dydžiu",
+    "close": "Uždaryti",
+    "withdraw": "Atšaukti",
+    "withdrawTitle": "Atšaukti šią oscilogramą?",
+    "withdrawConfirm": "Ji ištrinama kartu su failais, ir jokie kiti servisai jos nematys.",
+    "withdrawn": "Oscilograma atšaukta",
+    "withdrawFailed": "Šiuo metu nepavyko atšaukti oscilogramos. Bandykite dar kartą po akimirkos.",
+    "shareDialog": {
+      "title": "Dalintis oscilograma kodui {code}",
+      "description": "Kiti servisai ją pamatys, kai Torqvoice darbuotojas ją patikrins.",
+      "meaningHint": "Kituose valdymo blokuose šis kodas reiškia ką kita. Jūsų oscilograma dalinama tik šiai reikšmei.",
+      "kind": "Ką ji rodo?",
+      "kindHint": {
+        "good": "Kaip atrodo tvarkingame automobilyje",
+        "goodMarine": "Kaip atrodo tvarkingame variklyje",
+        "faulty": "Kaip atrodė esant gedimui",
+        "faultyMarine": "Kaip atrodė esant gedimui"
+      },
+      "measured": "Ką matavote?",
+      "measuredPlaceholder": "Alkūninio veleno jutiklis ir įsiurbimo skirstomasis velenas",
+      "measurements": {
+        "crank": "Alkūninio veleno jutiklis",
+        "camCrank": "Skirstomasis ir alkūninis velenai",
+        "injector": "Purkštukas",
+        "ignition": "Uždegimo ritė",
+        "compression": "Santykinė kompresija",
+        "lambda": "Lambda zondas",
+        "maf": "Oro srauto matuoklis",
+        "alternator": "Generatoriaus pulsacija",
+        "canBus": "CAN magistralė",
+        "fuelPump": "Kuro siurblio srovė"
+      },
+      "condition": "Kaip veikė variklis? (neprivaloma)",
+      "files": "Failai",
+      "filesHint": "Pakanka vieno. PicoScope failą kiti gali atidaryti PicoScope programoje; CSV eksportą ar nuotrauką jie mato čia pat.",
+      "phone": {
+        "button": "Nuotrauka iš telefono",
+        "title": "Nufotografuokite oscilografo ekraną",
+        "body": "Nuskenuokite kodą telefono kamera ir padarykite vieną ekrano nuotrauką. Ji atsiras čia, kai tik ateis.",
+        "received": "Nuotrauka iš jūsų telefono atėjo"
+      },
+      "drop": "Įmeskite PicoScope failą čia arba spustelėkite ir pasirinkite",
+      "dropMore": "Pridėti kitą failą",
+      "dropTypes": ".psdata, .pssettings, CSV eksportas arba ekrano nuotrauka",
+      "role": {
+        "psdata": "PicoScope įrašas",
+        "csv": "CSV eksportas",
+        "image": "Ekrano nuotrauka",
+        "pssettings": "Oscilografo nustatymai"
+      },
+      "csvNotScope": "Tai ne oscilografo eksportas. Išsaugokite jį iš PicoScope dar kartą kaip CSV.",
+      "remove": "Pašalinti {name}",
+      "fromPhone": "Iš jūsų telefono",
+      "previewHint": "Taip ją matys kiti servisai.",
+      "csvTip": "Patarimas: pridėkite ir CSV eksportą, kad kiti matytų oscilogramą čia neatidarę PicoScope. PicoScope programoje pasirinkite Išsaugoti kaip ir CSV.",
+      "note": "Pastabos (neprivaloma)",
+      "notePlaceholder": "Kur matavote, kokią skalę naudojote ir į ką žiūrėti. Pavyzdžiui: 2 kontaktas iš jutiklio jungties galo, trūkstamų dantų tarpas matosi kartą per apsisukimą.",
+      "notePlaceholderMarine": "Kur matavote, kokią skalę naudojote ir į ką žiūrėti.",
+      "sending": "Siunčiama {percent} %",
+      "send": "Dalintis oscilograma",
+      "sent": "Ačiū. Jūsų oscilograma laukia peržiūros.",
+      "missing": {
+        "kind": "Pasirinkite tvarkinga arba su gedimu",
+        "title": "Nurodykite, ką matavote",
+        "file": "Pridėkite failą arba nuotrauką",
+        "csv": "Pašalinkite CSV, kuris nėra oscilografo eksportas"
+      },
+      "notAScopeFile": "{name} nėra nei PicoScope failas, nei CSV eksportas, nei nuotrauka",
+      "fileTooBig": "{name} didesnis nei {size}",
+      "errors": {
+        "failed": "Šiuo metu nepavyko išsiųsti jūsų oscilogramos. Bandykite dar kartą po akimirkos.",
+        "photo": "Nepavyko perskaityti nuotraukos. Pabandykite kitą.",
+        "csv": "CSV nėra oscilografo eksportas. Išsaugokite jį iš PicoScope dar kartą kaip CSV.",
+        "too_large": "Failai per dideli.",
+        "no_file": "Pridėkite įrašą, CSV eksportą arba nuotrauką.",
+        "paused": "Dalijimasis oscilogramomis šiuo metu sustabdytas. Bandykite vėliau.",
+        "demo": "Demonstracinėje versijoje dalijimasis oscilogramomis išjungtas."
+      }
+    }
+  },
+  "improve": {
+    "button": "Padėkite mums tobulėti",
+    "title": "Padėkite mums patobulinti gedimų kodus",
+    "description": "Žinote, kur gamintojas skelbia savo kodus, pastebėjote klaidą ar turite idėją? Parašykite mums. Žinutė keliauja tiesiai Torqvoice komandai.",
+    "topic": "Apie ką tai? (neprivaloma)",
+    "topics": {
+      "makes": "Trūkstami kodai",
+      "wrong": "Kažkas negerai",
+      "source": "Geras šaltinis ar nuoroda",
+      "idea": "Idėja"
+    },
+    "message": "Jūsų žinutė",
+    "placeholders": {
+      "makes": "Kur juos rasti, pavyzdžiui, nuoroda į gamintojo kodų sąrašą, ir kuriems modeliams jis tinka.",
+      "wrong": "Koks kodas, kas parašyta ir kas turėtų būti parašyta. Nuoroda į šaltinį padeda.",
+      "source": "Įklijuokite nuorodą ir parašykite, kas joje yra.",
+      "idea": "Kas padarytų gedimų kodus naudingesnius jūsų servise? Nuorodos laukiamos."
+    },
+    "links": "Nuorodos jūsų žinutėje",
+    "about": "Apie {code}",
+    "leaveOut": "Neįtraukti",
+    "replyHint": "Atsakysime jūsų paskyros el. paštu.",
+    "cancel": "Atšaukti",
+    "send": "Siųsti",
+    "sent": "Ačiū. Jūsų žinutė keliauja Torqvoice komandai.",
+    "errors": {
+      "failed": "Šiuo metu nepavyko išsiųsti žinutės. Bandykite dar kartą po akimirkos.",
+      "limited": "Jau išsiuntėte kelias žinutes. Bandykite po valandos."
+    }
+  },
+  "import": {
+    "button": "Importuoti",
+    "title": "Importuoti gedimų kodus",
+    "description": "Pridėkite gamintojo savus kodus arba daugiau apie mūsų turimus kodus. Torqvoice darbuotojas patikrina kiekvieną kodą, kol jo dar nemato joks servisas.",
+    "yours": "Jūsų importai",
+    "codes": "{count, plural, one {# kodas} few {# kodai} other {# kodų}}",
+    "status": {
+      "published": "paskelbta: {count}",
+      "pending": "laukia: {count}",
+      "rejected": "nepaskelbta: {count}"
+    },
+    "examples": {
+      "title": "Pradėkite nuo pavyzdžio",
+      "sheet": "Pavyzdinė lentelė",
+      "sheetHint": "Excel, viena eilutė kodui",
+      "json": "Pavyzdinis JSON",
+      "jsonHint": "Kiekvienas laukas pavadintas",
+      "columns": "Palikite stulpelių pavadinimus: Code, Title, Description, Common issues, How to fix, Good to know ir Causes. Būtini tik Code ir Title. Kiekvieną priežastį rašykite atskiroje eilutėje, jei žinote, su (high), (medium) arba (low) gale.",
+      "csv": "Atsisiųsti kaip CSV"
+    },
+    "make": "Gamintojas",
+    "pickMake": "Pasirinkite gamintoją",
+    "language": "Teksto kalba",
+    "source": "Iš kur tai (neprivaloma)",
+    "sourcePlaceholder": "Nuoroda arba knyga",
+    "checking": "Skaitomi ir tikrinami jūsų kodai",
+    "removeFile": "Pašalinti failą",
+    "drop": "Įmeskite failą čia arba spustelėkite ir pasirinkite",
+    "dropTypes": ".xlsx, .csv arba .json, iki 2 000 kodų",
+    "fileProblems": {
+      "unreadable": "Nepavyko perskaityti failo. Išsaugokite jį dar kartą kaip .xlsx, .csv arba .json.",
+      "empty": "Faile nėra kodų.",
+      "no_code_column": "Nerastas stulpelis Code. Pradėkite nuo pavyzdinės lentelės.",
+      "too_many": "Failas per didelis: daugiausia 2 000 kodų ir 5 MB. Padalykite jį į dalis.",
+      "format": "Šis failo tipas nepalaikomas. Naudokite .xlsx, .csv arba .json.",
+      "legacy_xls": "Seni .xls failai nepalaikomi. Išsaugokite jį Excel kaip .xlsx."
+    },
+    "counts": {
+      "new": "nauji: {count}",
+      "change": "papildo mūsų: {count}",
+      "problem": "reikia pataisyti: {count}",
+      "same": "jau tokie patys: {count}"
+    },
+    "rows": "Kodai faile",
+    "row": "{n} eilutė",
+    "more": "ir dar {count}",
+    "problemsLeftOut": "Kodai, kuriuos reikia pataisyti, praleidžiami. Pataisykite juos faile ir įmeskite iš naujo arba siųskite likusius dabar.",
+    "rights": "Galiu tuo dalintis. Tai nenukopijuota iš mokamo ar saugomo šaltinio.",
+    "missing": {
+      "make": "Pirmiausia pasirinkite gamintoją",
+      "file": "Pridėkite failą",
+      "nothing": "Niekas faile nepapildo to, ką turime",
+      "rights": "Pažymėkite langelį, kad galėtumėte siųsti"
+    },
+    "reviewHint": "Niekas neskelbiama, kol to nepatikrina žmogus.",
+    "cancel": "Atšaukti",
+    "send": "{count, plural, one {Siųsti # kodą peržiūrai} few {Siųsti # kodus peržiūrai} other {Siųsti # kodų peržiūrai}}",
+    "sendNone": "Siųsti peržiūrai",
+    "sent": "{count, plural, one {Išsiųsta. # kodas laukia peržiūros.} few {Išsiųsta. # kodai laukia peržiūros.} other {Išsiųsta. # kodų laukia peržiūros.}}",
+    "errors": {
+      "failed": "Šiuo metu nepavyko patikrinti ar išsiųsti failo. Bandykite dar kartą po akimirkos.",
+      "paused": "Kodų siuntimas šiuo metu sustabdytas. Bandykite vėliau.",
+      "nothing": "Niekas faile nepapildo to, ką turime.",
+      "demo": "Demonstracinėje versijoje kodų importas išjungtas.",
+      "rights": "Pažymėkite langelį, kad galite tuo dalintis.",
+      "too_large": "Failas per didelis: daugiausia 2 000 kodų ir 5 MB."
+    },
+    "problems": {
+      "code": "Tai ne {make} naudojamas kodas",
+      "duplicate": "Tas pats kodas faile du kartus",
+      "title": "Reikia pavadinimo",
+      "empty": "Nieko neparašyta",
+      "too_long": "Per ilga: pavadinimas iki 200 simbolių, kiekvienas tekstas iki 4 000",
+      "likelihood": "Tikimybė turi būti low, medium arba high",
+      "meanings": "{make} šį kodą naudoja daugiau nei vienam dalykui. Geriau pasidalinkite juo kodo puslapyje."
+    },
+    "state": {
+      "new": "Naujas",
+      "change": "Papildo: {what}"
+    },
+    "fields": {
+      "title": "pavadinimas",
+      "description": "reikšmė",
+      "common_issues": "dažnos problemos",
+      "resolution": "kaip pataisyti",
+      "advisory": "verta žinoti",
+      "causes": "priežastys"
+    },
+    "addsCauses": "{count, plural, one {# priežastis} few {# priežastys} other {# priežasčių}}",
+    "searchMake": "Ieškoti gamintojų",
+    "noMake": "Gamintojas nerastas",
+    "useMake": "Naudoti {name}",
+    "groups": {
+      "cars": "Automobiliai ir furgonai",
+      "marine": "Laivai ir varikliai"
+    }
+  },
+  "makeCodes": {
+    "heading": "Gamintojai, turintys savo {code} reikšmę",
+    "thisCar": "Šio automobilio gamintojas"
   }
   }
 }
 }

+ 351 - 3
messages/nb/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Fra produsentene",
     "manufacturer": "Fra produsentene",
     "related": "Sammen med andre koder",
     "related": "Sammen med andre koder",
     "diagnosis": "Diagnosetips",
     "diagnosis": "Diagnosetips",
+    "common_issues": "Vanlige problemer",
+    "resolution": "Løsning",
+    "advisory": "Råd",
     "other": "Greit å vite"
     "other": "Greit å vite"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modell",
     "model": "Modell",
     "year": "År",
     "year": "År",
     "vehicleHint": "Bare merke, modell, år og motor deles, aldri skiltnummer, VIN, kunde eller navnet på verkstedet ditt.",
     "vehicleHint": "Bare merke, modell, år og motor deles, aldri skiltnummer, VIN, kunde eller navnet på verkstedet ditt.",
+    "meaning": "Betydning",
+    "meaningHint": "Denne koden betyr noe annet i andre styreenheter. Løsningen din deles bare for denne betydningen.",
     "vehicleHintMarine": "Bare merke, modell, år og motor deles, aldri registreringsnummer, skrognummer, kunde eller navnet på verkstedet ditt.",
     "vehicleHintMarine": "Bare merke, modell, år og motor deles, aldri registreringsnummer, skrognummer, kunde eller navnet på verkstedet ditt.",
     "send": "Del løsning",
     "send": "Del løsning",
     "sent": "Takk. Løsningen din venter på gjennomgang.",
     "sent": "Takk. Løsningen din venter på gjennomgang.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Legg til koder} one {Legg til # kode} other {Legg til # koder}}",
     "add": "{count, plural, =0 {Legg til koder} one {Legg til # kode} other {Legg til # koder}}",
     "added": "{count, plural, one {# feilkode lagt til} other {# feilkoder lagt til}}",
     "added": "{count, plural, one {# feilkode lagt til} other {# feilkoder lagt til}}",
     "skipped": "{count, plural, one {# kode var allerede på denne jobben} other {# koder var allerede på denne jobben}}",
     "skipped": "{count, plural, one {# kode var allerede på denne jobben} other {# koder var allerede på denne jobben}}",
-    "failed": "Kunne ikke legge til kodene"
+    "failed": "Kunne ikke legge til kodene",
+    "descriptionOem": "Skriv eller lim inn koder fra skanneren, OBD-koder som P0420 eller BMW-koder som 2A82. Hver kode blir en observasjon på denne jobben, med betydningen lagret.",
+    "placeholderOem": "P0420, 2A82 lagret"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Sett {date}",
     "seenBefore": "Sett {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Feilkoder er nytt",
     "title": "Feilkoder er nytt",
-    "body": "Vi legger til koder hele tiden, så ikke alle koder er her ennå. Foreløpig dekker det standard OBD-II-koder; produsentspesifikke koder er ikke med ennå."
+    "body": "Dette dekker standard OBD-II-koder. Vi jobber med å legge til produsentenes egne koder, og nye koder kommer hele tiden. Vet du hvor man finner et merkes koder? Si fra via «Hjelp oss å bli bedre».",
+    "bodyBmw": "Dette dekker standard OBD-II-koder og BMWs egne koder, som MINI og Rolls-Royce deler. Vi jobber med å legge til flere merker, og nye koder kommer hele tiden. Vet du hvor man finner et merkes koder? Si fra via «Hjelp oss å bli bedre».",
+    "bodyMakes": "Dette dekker standard OBD-II-koder og egne koder fra {makes}. Vi jobber med å legge til flere merker, og nye koder kommer hele tiden. Vet du hvor man finner et merkes koder? Si fra via «Hjelp oss å bli bedre»."
   },
   },
   "support": {
   "support": {
     "button": "Kontakt support",
     "button": "Kontakt support",
@@ -245,6 +254,345 @@
     "done": "Når du er ferdig, gå tilbake til datamaskinen og del løsningen.",
     "done": "Når du er ferdig, gå tilbake til datamaskinen og del løsningen.",
     "privacy": "Hold ansikter, skilt og kundeopplysninger utenfor bildet.",
     "privacy": "Hold ansikter, skilt og kundeopplysninger utenfor bildet.",
     "expired": "Denne koden har gått ut. Vis en ny på datamaskinen.",
     "expired": "Denne koden har gått ut. Vis en ny på datamaskinen.",
-    "invalid": "Denne koden virker ikke. Vis en ny på datamaskinen."
+    "invalid": "Denne koden virker ikke. Vis en ny på datamaskinen.",
+    "waveform": {
+      "title": "Bilde av oscilloskopskjermen",
+      "forCode": "til målekurven du deler på datamaskinen",
+      "intro": "Ta eller velg ett bilde av oscilloskopskjermen. Det dukker opp på datamaskinen.",
+      "full": "En målekurve har ett bilde. Fjern det på datamaskinen for å ta et nytt.",
+      "done": "Gå tilbake til datamaskinen og del målekurven.",
+      "added": "Bildet er lagt til"
+    }
+  },
+  "oem": {
+    "badge": "{make}-kode",
+    "summary": "{count, plural, one {# betydning} other {# betydninger}}. En BMW-kode kan bety noe annet i hver styreenhet.",
+    "matches": "Passer denne bilen",
+    "noMatch": "Ingen av disse betydningene er oppført for motoren i denne bilen. Sjekk hvilken styreenhet skanneren leste koden fra.",
+    "inEnglish": "{make}s engelske tekst: {text}",
+    "bmwText": "{make}s tekst: {text}",
+    "units": "Styreenheter",
+    "moreUnits": "+{count} til",
+    "otherChassis": "Andre karosserier",
+    "years": "{from} til {to}",
+    "yearsFrom": "fra {from}",
+    "source": "Betydningene kommer fra BMWs egne diagnosetekster, de fleste på tysk.",
+    "notServed": "{make}-koder er ikke tilgjengelige ennå.",
+    "notFound": "{code} finnes ikke i {make}s data.",
+    "whereUsed": "Hvor {make} bruker den",
+    "models": "Modeller",
+    "engineList": "Motorer",
+    "fewerUnits": "Vis færre",
+    "fitsThisCar": "Denne bilen",
+    "meanings": "Betydninger",
+    "otherMeanings": "og {count, plural, one {# annen betydning} other {# andre betydninger}}"
+  },
+  "research": {
+    "inviteTitle": "Finn en løsning på nett",
+    "invite": "Vi søker etter {code} i produsentdokumentasjon, servicemeldinger og verkstedforum. Bare et konkret svar med kilder lagres, og en person hos Torqvoice sjekker det.",
+    "button": "Finn en løsning",
+    "working": "Undersøker {code}",
+    "steps": {
+      "bulletins": "Søker i produsentdokumentasjon og servicemeldinger",
+      "forums": "Leser verkstedforum for bekreftede løsninger",
+      "agree": "Sjekker at kildene stemmer overens",
+      "writing": "Skriver opp stegene"
+    },
+    "workingHint": "Dette tar et minutt eller to. Det som blir funnet, blir liggende på koden for alle verksteder.",
+    "runningTitle": "Undersøkes akkurat nå",
+    "runningHint": "Et annet verksted spurte nettopp. Sjekk igjen om et minutt.",
+    "checkAgain": "Sjekk igjen",
+    "title": "Funnet løsning",
+    "pending": "Venter på at en person sjekker den",
+    "published": "Sjekket av Torqvoice",
+    "pendingHint": "Funnet av KI fra kildene under. Sjekk mot bilen før du bytter deler.",
+    "sources": "Kilder",
+    "helpfulQuestion": "Løste dette det?",
+    "helpfulYes": "Ja, det løste det",
+    "helpfulNo": "Nei, det gjorde det ikke",
+    "thanks": "Takk, notert",
+    "missedTitle": "Fant ingen pålitelig løsning på nett",
+    "missed": "Ingenting konkret om {code} dukket opp, så ingenting ble lagret. Vi har notert at verksteder trenger denne koden.",
+    "searchedOn": "Søkt {date}",
+    "shareInstead": "Del løsningen din",
+    "errors": {
+      "busy": "Denne koden undersøkes akkurat nå. Sjekk igjen om et minutt.",
+      "limited": "Verkstedet ditt har brukt dagens søk. Prøv igjen i morgen.",
+      "failed": "Søket kunne ikke kjøres nå. Prøv igjen senere."
+    }
+  },
+  "waveforms": {
+    "title": "Målekurver",
+    "description": "Oscilloskopopptak fra andre verksteder, gode og feilaktige, å holde opp mot det du måler.",
+    "share": "Del en målekurve",
+    "unavailable": "Målekurver er ikke tilgjengelige akkurat nå.",
+    "empty": "Ingen målekurver for {code} ennå",
+    "emptyHint": "Har du et opptak av en god kurve? Del det, så har neste tekniker som jakter denne koden noe å sammenligne med.",
+    "filter": {
+      "label": "Vis",
+      "all": "Alle",
+      "good": "God kurve",
+      "faulty": "Feil"
+    },
+    "unknownVehicle": "Kjøretøy ikke oppgitt",
+    "unknownVehicleMarine": "Båt ikke oppgitt",
+    "status": {
+      "pending": "Venter på gjennomgang",
+      "published": "Publisert",
+      "rejected": "Ikke publisert"
+    },
+    "hasRecording": "PicoScope-fil å laste ned",
+    "hasCsv": "CSV-eksport å laste ned",
+    "kind": {
+      "good": "God kurve",
+      "faulty": "Feil"
+    },
+    "condition": {
+      "key_on": "Tenning på, motor av",
+      "cranking": "Starting",
+      "idle": "Tomgang",
+      "rpm_2500": "2500 o/min",
+      "driving": "Under kjøring",
+      "drivingMarine": "Under fart",
+      "under_load": "Under belastning"
+    },
+    "chart": {
+      "label": "Målekurve av {channels} over {span}",
+      "perDivision": "{value}/rute",
+      "noSample": "ingen måling",
+      "between": "{lo} til {hi}"
+    },
+    "view": {
+      "label": "Vis",
+      "trace": "Målekurve",
+      "picture": "Bilde av skjermen"
+    },
+    "sharedOn": "Delt {date}",
+    "reason": "Begrunnelse: {reason}",
+    "compare": {
+      "label": "Sammenlign med",
+      "none": "Ingenting",
+      "close": "Slutt å sammenligne"
+    },
+    "note": "Merknader",
+    "writtenIn": "Skrevet på {language}",
+    "files": {
+      "title": "Filer",
+      "psdata": "PicoScope-opptak",
+      "csv": "CSV-eksport",
+      "pssettings": "Oscilloskopinnstillinger",
+      "openHint": "Åpne opptaket i PicoScope for å zoome, måle og se alle kanalene."
+    },
+    "nothingToDraw": "Bare opptaket ble delt. Last det ned og åpne det i PicoScope.",
+    "openPicture": "Åpne bildet i full størrelse",
+    "close": "Lukk",
+    "withdraw": "Trekk tilbake",
+    "withdrawTitle": "Trekke tilbake denne målekurven?",
+    "withdrawConfirm": "Den slettes med filene sine, og ingen andre verksteder ser den.",
+    "withdrawn": "Målekurven er trukket tilbake",
+    "withdrawFailed": "Målekurven kunne ikke trekkes tilbake akkurat nå. Prøv igjen om litt.",
+    "shareDialog": {
+      "title": "Del en målekurve for {code}",
+      "description": "Andre verksteder ser den når en person i Torqvoice har sjekket den.",
+      "meaningHint": "Denne koden betyr noe annet i andre styreenheter. Målekurven din deles bare for denne betydningen.",
+      "kind": "Hva viser den?",
+      "kindHint": {
+        "good": "Slik ser det ut på en bil uten feil",
+        "goodMarine": "Slik ser det ut på en motor uten feil",
+        "faulty": "Slik så det ut med feilen",
+        "faultyMarine": "Slik så det ut med feilen"
+      },
+      "measured": "Hva målte du?",
+      "measuredPlaceholder": "Veivakselsensor og innsugskamaksel",
+      "measurements": {
+        "crank": "Veivakselsensor",
+        "camCrank": "Kamaksel og veivaksel",
+        "injector": "Innsprøytningsventil",
+        "ignition": "Tennspole",
+        "compression": "Relativ kompresjon",
+        "lambda": "Lambdasonde",
+        "maf": "Luftmassemåler",
+        "alternator": "Rippel fra dynamo",
+        "canBus": "CAN-buss",
+        "fuelPump": "Strøm til drivstoffpumpe"
+      },
+      "condition": "Hvordan gikk motoren? (valgfritt)",
+      "files": "Filer",
+      "filesHint": "Én holder. PicoScope-filen lar andre åpne den i PicoScope; en CSV-eksport eller et bilde lar dem se den rett her.",
+      "phone": {
+        "button": "Bilde fra mobilen",
+        "title": "Ta et bilde av oscilloskopskjermen",
+        "body": "Skann koden med kameraet på mobilen og ta ett bilde av skjermen. Det dukker opp her når det kommer.",
+        "received": "Bildet fra mobilen din har kommet"
+      },
+      "drop": "Slipp PicoScope-filen her, eller klikk for å velge",
+      "dropMore": "Legg til en fil til",
+      "dropTypes": ".psdata, .pssettings, en CSV-eksport eller et bilde av skjermen",
+      "role": {
+        "psdata": "PicoScope-opptak",
+        "csv": "CSV-eksport",
+        "image": "Bilde av skjermen",
+        "pssettings": "Oscilloskopinnstillinger"
+      },
+      "csvNotScope": "Dette er ikke en eksport fra et oscilloskop. Lagre den på nytt fra PicoScope som CSV.",
+      "remove": "Fjern {name}",
+      "fromPhone": "Fra mobilen din",
+      "previewHint": "Slik ser andre verksteder den.",
+      "csvTip": "Tips: legg også ved en CSV-eksport, så kan andre se kurven her uten å åpne PicoScope. I PicoScope velger du Lagre som og CSV.",
+      "note": "Merknader (valgfritt)",
+      "notePlaceholder": "Hvor du målte, hvilket område du brukte og hva man skal se etter. For eksempel: bakmålt pinne 2 i sensorpluggen, hullet for de manglende tennene vises én gang per omdreining.",
+      "notePlaceholderMarine": "Hvor du målte, hvilket område du brukte og hva man skal se etter.",
+      "sending": "Sender {percent} %",
+      "send": "Del målekurve",
+      "sent": "Takk. Målekurven din venter på gjennomgang.",
+      "missing": {
+        "kind": "Velg god kurve eller feil",
+        "title": "Skriv hva du målte",
+        "file": "Legg til en fil eller et bilde",
+        "csv": "Fjern CSV-filen som ikke er en oscilloskopeksport"
+      },
+      "notAScopeFile": "{name} er verken en PicoScope-fil, en CSV-eksport eller et bilde",
+      "fileTooBig": "{name} er større enn {size}",
+      "errors": {
+        "failed": "Målekurven din kunne ikke sendes akkurat nå. Prøv igjen om litt.",
+        "photo": "Bildet kunne ikke leses. Prøv et annet.",
+        "csv": "CSV-filen er ikke en eksport fra et oscilloskop. Lagre den på nytt fra PicoScope som CSV.",
+        "too_large": "Filene er for store.",
+        "no_file": "Legg til opptaket, en CSV-eksport eller et bilde.",
+        "paused": "Deling av målekurver er satt på pause akkurat nå. Prøv igjen senere.",
+        "demo": "Deling av målekurver er slått av i demoen."
+      }
+    }
+  },
+  "improve": {
+    "button": "Hjelp oss å bli bedre",
+    "title": "Hjelp oss å gjøre feilkodene bedre",
+    "description": "Vet du hvor et bilmerke publiserer sine egne koder, har du sett en feil, eller har du en idé? Si fra. Det går rett til Torqvoice-teamet.",
+    "topic": "Hva gjelder det? (valgfritt)",
+    "topics": {
+      "makes": "Koder vi mangler",
+      "wrong": "Noe er feil",
+      "source": "En god kilde eller lenke",
+      "idea": "En idé"
+    },
+    "message": "Meldingen din",
+    "placeholders": {
+      "makes": "Hvor de finnes, for eksempel en lenke til merkets kodeliste, og hvilke modeller den gjelder.",
+      "wrong": "Hvilken kode, hva det står og hva det burde stå. En lenke til hvor du fant det hjelper.",
+      "source": "Lim inn lenken og skriv hva den inneholder.",
+      "idea": "Hva ville gjort feilkodene mer nyttige på verkstedet ditt? Lenker er velkomne."
+    },
+    "links": "Lenker i meldingen din",
+    "about": "Om {code}",
+    "leaveOut": "Ta bort",
+    "replyHint": "Vi svarer til e-posten på kontoen din.",
+    "cancel": "Avbryt",
+    "send": "Send",
+    "sent": "Takk. Meldingen din er på vei til Torqvoice-teamet.",
+    "errors": {
+      "failed": "Meldingen din kunne ikke sendes akkurat nå. Prøv igjen om litt.",
+      "limited": "Du har allerede sendt noen meldinger. Prøv igjen om en time."
+    }
+  },
+  "import": {
+    "button": "Importer",
+    "title": "Importer feilkoder",
+    "description": "Legg til et merkes egne koder, eller mer om koder vi har. En person i Torqvoice sjekker hver kode før noe verksted ser den.",
+    "yours": "Dine importer",
+    "codes": "{count, plural, one {# kode} other {# koder}}",
+    "status": {
+      "published": "{count} publisert",
+      "pending": "{count} venter",
+      "rejected": "{count} ikke publisert"
+    },
+    "examples": {
+      "title": "Start med et eksempel",
+      "sheet": "Eksempelregneark",
+      "sheetHint": "Excel, én rad per kode",
+      "json": "Eksempel-JSON",
+      "jsonHint": "Hvert felt navngitt",
+      "columns": "Behold kolonnenavnene: Code, Title, Description, Common issues, How to fix, Good to know og Causes. Bare Code og Title trengs. Skriv hver årsak på egen linje, med (high), (medium) eller (low) etter hvis du vet det.",
+      "csv": "Last det ned som CSV"
+    },
+    "make": "Merke",
+    "pickMake": "Velg et merke",
+    "language": "Språket i teksten",
+    "source": "Hvor det kommer fra (valgfritt)",
+    "sourcePlaceholder": "Lenke eller bok",
+    "checking": "Leser og sjekker kodene dine",
+    "removeFile": "Fjern filen",
+    "drop": "Slipp filen her, eller klikk for å velge",
+    "dropTypes": ".xlsx, .csv eller .json, opptil 2 000 koder",
+    "fileProblems": {
+      "unreadable": "Filen kunne ikke leses. Lagre den på nytt som .xlsx, .csv eller .json.",
+      "empty": "Filen har ingen koder.",
+      "no_code_column": "Fant ingen Code-kolonne. Start med eksempelregnearket.",
+      "too_many": "Filen er for stor: høyst 2 000 koder og 5 MB. Del den opp.",
+      "format": "Denne filtypen støttes ikke. Bruk .xlsx, .csv eller .json.",
+      "legacy_xls": "Gamle .xls-filer støttes ikke. Lagre den som .xlsx i Excel."
+    },
+    "counts": {
+      "new": "{count} nye",
+      "change": "{count} utfyller våre",
+      "problem": "{count} må rettes",
+      "same": "{count} er allerede like"
+    },
+    "rows": "Koder i filen",
+    "row": "Rad {n}",
+    "more": "og {count} til",
+    "problemsLeftOut": "Koder som må rettes, utelates. Rett dem i filen og slipp den inn igjen, eller send resten nå.",
+    "rights": "Jeg har lov til å dele dette. Det er ikke kopiert fra en betalt eller beskyttet kilde.",
+    "missing": {
+      "make": "Velg merket først",
+      "file": "Legg til filen din",
+      "nothing": "Ingenting i filen utfyller det vi har",
+      "rights": "Huk av i boksen for å sende"
+    },
+    "reviewHint": "Ingenting publiseres før en person har sjekket det.",
+    "cancel": "Avbryt",
+    "send": "{count, plural, one {Send # kode til gjennomgang} other {Send # koder til gjennomgang}}",
+    "sendNone": "Send til gjennomgang",
+    "sent": "{count, plural, one {Sendt. # kode venter på gjennomgang.} other {Sendt. # koder venter på gjennomgang.}}",
+    "errors": {
+      "failed": "Filen kunne ikke sjekkes eller sendes akkurat nå. Prøv igjen om litt.",
+      "paused": "Sending av koder er satt på pause akkurat nå. Prøv igjen senere.",
+      "nothing": "Ingenting i filen utfyller det vi har.",
+      "demo": "Import av koder er slått av i demoen.",
+      "rights": "Huk av for at du har lov til å dele dette.",
+      "too_large": "Filen er for stor: høyst 2 000 koder og 5 MB."
+    },
+    "problems": {
+      "code": "Ikke en kode {make} bruker",
+      "duplicate": "Samme kode to ganger i filen",
+      "title": "Trenger en tittel",
+      "empty": "Ingenting skrevet",
+      "too_long": "For lang: en tittel opptil 200 tegn, hver tekst opptil 4 000",
+      "likelihood": "Sannsynligheten må være low, medium eller high",
+      "meanings": "{make} bruker denne koden til mer enn én ting. Del det heller på kodens side."
+    },
+    "state": {
+      "new": "Ny",
+      "change": "Legger til {what}"
+    },
+    "fields": {
+      "title": "tittel",
+      "description": "hva den betyr",
+      "common_issues": "vanlige problemer",
+      "resolution": "hvordan fikse",
+      "advisory": "greit å vite",
+      "causes": "årsaker"
+    },
+    "addsCauses": "{count, plural, one {# årsak} other {# årsaker}}",
+    "searchMake": "Søk etter merke",
+    "noMake": "Fant ingen merker",
+    "useMake": "Bruk {name}",
+    "groups": {
+      "cars": "Biler og varebiler",
+      "marine": "Båter og motorer"
+    }
+  },
+  "makeCodes": {
+    "heading": "Merker med egen betydning for {code}",
+    "thisCar": "Bilens merke"
   }
   }
 }
 }

+ 351 - 3
messages/nl/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Gegevens van fabrikanten",
     "manufacturer": "Gegevens van fabrikanten",
     "related": "Samen met andere codes",
     "related": "Samen met andere codes",
     "diagnosis": "Diagnosetip",
     "diagnosis": "Diagnosetip",
+    "common_issues": "Veelvoorkomende problemen",
+    "resolution": "Oplossing",
+    "advisory": "Advies",
     "other": "Goed om te weten"
     "other": "Goed om te weten"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Model",
     "model": "Model",
     "year": "Bouwjaar",
     "year": "Bouwjaar",
     "vehicleHint": "Alleen merk, model, bouwjaar en motor worden gedeeld, nooit het kenteken, het VIN, de klant of de naam van uw werkplaats.",
     "vehicleHint": "Alleen merk, model, bouwjaar en motor worden gedeeld, nooit het kenteken, het VIN, de klant of de naam van uw werkplaats.",
+    "meaning": "Betekenis",
+    "meaningHint": "Deze code betekent in andere regeleenheden iets anders. Uw oplossing wordt alleen voor deze betekenis gedeeld.",
     "vehicleHintMarine": "Alleen merk, model, bouwjaar en motor worden gedeeld, nooit het registratienummer, het rompnummer, de klant of de naam van uw werkplaats.",
     "vehicleHintMarine": "Alleen merk, model, bouwjaar en motor worden gedeeld, nooit het registratienummer, het rompnummer, de klant of de naam van uw werkplaats.",
     "send": "Oplossing delen",
     "send": "Oplossing delen",
     "sent": "Bedankt. Uw oplossing wacht op controle.",
     "sent": "Bedankt. Uw oplossing wacht op controle.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Codes toevoegen} one {# code toevoegen} other {# codes toevoegen}}",
     "add": "{count, plural, =0 {Codes toevoegen} one {# code toevoegen} other {# codes toevoegen}}",
     "added": "{count, plural, one {# foutcode toegevoegd} other {# foutcodes toegevoegd}}",
     "added": "{count, plural, one {# foutcode toegevoegd} other {# foutcodes toegevoegd}}",
     "skipped": "{count, plural, one {# code stond al bij deze klus} other {# codes stonden al bij deze klus}}",
     "skipped": "{count, plural, one {# code stond al bij deze klus} other {# codes stonden al bij deze klus}}",
-    "failed": "De codes konden niet worden toegevoegd"
+    "failed": "De codes konden niet worden toegevoegd",
+    "descriptionOem": "Typ of plak codes uit het diagnoseapparaat, OBD-codes zoals P0420 of BMW-codes zoals 2A82. Elke code wordt een observatie op deze opdracht, met de betekenis opgeslagen.",
+    "placeholderOem": "P0420, 2A82 opgeslagen"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Gezien {date}",
     "seenBefore": "Gezien {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Foutcodes zijn nieuw",
     "title": "Foutcodes zijn nieuw",
-    "body": "We voegen voortdurend codes toe, dus nog niet elke code staat erin. Voorlopig gaat het om de standaard OBD-II-codes; fabrikantspecifieke codes zitten er nog niet in."
+    "body": "Het gaat om de standaard OBD-II-codes. We werken eraan om ook de eigen codes van merken toe te voegen, en er komen steeds nieuwe codes bij. Weet je waar de codes van een merk te vinden zijn? Laat het ons weten via “Help ons verbeteren”.",
+    "bodyBmw": "Het gaat om de standaard OBD-II-codes en de eigen codes van BMW, die MINI en Rolls-Royce delen. We werken eraan om meer merken toe te voegen, en er komen steeds nieuwe codes bij. Weet je waar de codes van een merk te vinden zijn? Laat het ons weten via “Help ons verbeteren”.",
+    "bodyMakes": "Het gaat om de standaard OBD-II-codes en de eigen codes van {makes}. We werken eraan om meer merken toe te voegen, en er komen steeds nieuwe codes bij. Weet je waar de codes van een merk te vinden zijn? Laat het ons weten via “Help ons verbeteren”."
   },
   },
   "support": {
   "support": {
     "button": "Contact met support",
     "button": "Contact met support",
@@ -245,6 +254,345 @@
     "done": "Als u klaar bent, ga dan terug naar de computer en deel de oplossing.",
     "done": "Als u klaar bent, ga dan terug naar de computer en deel de oplossing.",
     "privacy": "Houd gezichten, kentekens en klantgegevens buiten beeld.",
     "privacy": "Houd gezichten, kentekens en klantgegevens buiten beeld.",
     "expired": "Deze code is verlopen. Toon een nieuwe op de computer.",
     "expired": "Deze code is verlopen. Toon een nieuwe op de computer.",
-    "invalid": "Deze code werkt niet. Toon een nieuwe op de computer."
+    "invalid": "Deze code werkt niet. Toon een nieuwe op de computer.",
+    "waveform": {
+      "title": "Foto van het scopescherm",
+      "forCode": "voor het meetbeeld dat je op de computer deelt",
+      "intro": "Maak of kies één foto van het scopescherm. Hij verschijnt op de computer.",
+      "full": "Een meetbeeld heeft één foto. Verwijder hem op de computer om een nieuwe te maken.",
+      "done": "Ga terug naar de computer en deel het meetbeeld.",
+      "added": "Foto toegevoegd"
+    }
+  },
+  "oem": {
+    "badge": "{make}-code",
+    "summary": "{count, plural, one {# betekenis} other {# betekenissen}}. Een BMW-code kan in elke regeleenheid iets anders betekenen.",
+    "matches": "Past bij deze auto",
+    "noMatch": "Geen van deze betekenissen staat vermeld voor de motor van deze auto. Controleer uit welke regeleenheid uw diagnoseapparaat de code las.",
+    "inEnglish": "Engelse tekst van {make}: {text}",
+    "bmwText": "Tekst van {make}: {text}",
+    "units": "Regeleenheden",
+    "moreUnits": "+{count} meer",
+    "otherChassis": "Andere chassis",
+    "years": "{from} tot {to}",
+    "yearsFrom": "vanaf {from}",
+    "source": "De betekenissen komen uit de eigen diagnoseteksten van BMW, de meeste in het Duits.",
+    "notServed": "{make}-codes zijn nog niet beschikbaar.",
+    "notFound": "{code} staat niet in de gegevens van {make}.",
+    "whereUsed": "Waar {make} hem gebruikt",
+    "models": "Modellen",
+    "engineList": "Motoren",
+    "fewerUnits": "Minder tonen",
+    "fitsThisCar": "Deze auto",
+    "meanings": "Betekenissen",
+    "otherMeanings": "en {count, plural, one {# andere betekenis} other {# andere betekenissen}}"
+  },
+  "research": {
+    "inviteTitle": "Online een oplossing zoeken",
+    "invite": "We zoeken {code} op in fabrieksdocumentatie, servicebulletins en werkplaatsforums. Alleen een concreet antwoord met bronnen wordt bewaard, en iemand van Torqvoice controleert het.",
+    "button": "Oplossing zoeken",
+    "working": "Onderzoek naar {code}",
+    "steps": {
+      "bulletins": "Zoeken in fabrieksdocumentatie en servicebulletins",
+      "forums": "Werkplaatsforums lezen voor bevestigde oplossingen",
+      "agree": "Controleren of de bronnen overeenkomen",
+      "writing": "De stappen uitschrijven"
+    },
+    "workingHint": "Dit duurt een minuut of twee. Wat gevonden wordt, blijft bij de code voor alle werkplaatsen.",
+    "runningTitle": "Wordt nu onderzocht",
+    "runningHint": "Een andere werkplaats vroeg er net om. Kijk over een minuut opnieuw.",
+    "checkAgain": "Opnieuw kijken",
+    "title": "Onderzochte oplossing",
+    "pending": "Wacht op controle door een persoon",
+    "published": "Gecontroleerd door Torqvoice",
+    "pendingHint": "Door AI gevonden uit de bronnen hieronder. Controleer het aan de auto voordat u onderdelen vervangt.",
+    "sources": "Bronnen",
+    "helpfulQuestion": "Heeft dit het opgelost?",
+    "helpfulYes": "Ja, het is opgelost",
+    "helpfulNo": "Nee, niet opgelost",
+    "thanks": "Bedankt, genoteerd",
+    "missedTitle": "Geen betrouwbare oplossing online gevonden",
+    "missed": "Er kwam niets concreets over {code} naar boven, dus er is niets bewaard. We hebben genoteerd dat werkplaatsen deze code nodig hebben.",
+    "searchedOn": "Gezocht op {date}",
+    "shareInstead": "Uw oplossing delen",
+    "errors": {
+      "busy": "Deze code wordt nu onderzocht. Kijk over een minuut opnieuw.",
+      "limited": "Uw werkplaats heeft de zoekopdrachten van vandaag gebruikt. Probeer het morgen opnieuw.",
+      "failed": "Het onderzoek kon nu niet worden uitgevoerd. Probeer het later opnieuw."
+    }
+  },
+  "waveforms": {
+    "title": "Meetbeelden",
+    "description": "Scoopopnames van andere werkplaatsen, goed en defect, om naast je eigen meting te leggen.",
+    "share": "Meetbeeld delen",
+    "unavailable": "Meetbeelden zijn nu niet beschikbaar.",
+    "empty": "Nog geen meetbeelden voor {code}",
+    "emptyHint": "Heb je een goed meetbeeld? Deel het, dan heeft de volgende monteur met deze code iets om mee te vergelijken.",
+    "filter": {
+      "label": "Toon",
+      "all": "Alle",
+      "good": "Goed",
+      "faulty": "Defect"
+    },
+    "unknownVehicle": "Voertuig niet opgegeven",
+    "unknownVehicleMarine": "Boot niet opgegeven",
+    "status": {
+      "pending": "Wacht op controle",
+      "published": "Gepubliceerd",
+      "rejected": "Niet gepubliceerd"
+    },
+    "hasRecording": "PicoScope-bestand om te downloaden",
+    "hasCsv": "CSV-export om te downloaden",
+    "kind": {
+      "good": "Goed",
+      "faulty": "Defect"
+    },
+    "condition": {
+      "key_on": "Contact aan, motor uit",
+      "cranking": "Starten",
+      "idle": "Stationair",
+      "rpm_2500": "2500 tpm",
+      "driving": "Tijdens het rijden",
+      "drivingMarine": "Tijdens het varen",
+      "under_load": "Onder belasting"
+    },
+    "chart": {
+      "label": "Meetbeeld van {channels} over {span}",
+      "perDivision": "{value}/div",
+      "noSample": "geen meetwaarde",
+      "between": "{lo} tot {hi}"
+    },
+    "view": {
+      "label": "Toon",
+      "trace": "Meetbeeld",
+      "picture": "Foto van het scherm"
+    },
+    "sharedOn": "Gedeeld op {date}",
+    "reason": "Reden: {reason}",
+    "compare": {
+      "label": "Vergelijk met",
+      "none": "Niets",
+      "close": "Stop met vergelijken"
+    },
+    "note": "Opmerkingen",
+    "writtenIn": "Geschreven in het {language}",
+    "files": {
+      "title": "Bestanden",
+      "psdata": "PicoScope-opname",
+      "csv": "CSV-export",
+      "pssettings": "Scope-instellingen",
+      "openHint": "Open de opname in PicoScope om in te zoomen, te meten en elk kanaal te zien."
+    },
+    "nothingToDraw": "Alleen de opname is gedeeld. Download hem om hem in PicoScope te openen.",
+    "openPicture": "Foto op volle grootte openen",
+    "close": "Sluiten",
+    "withdraw": "Intrekken",
+    "withdrawTitle": "Dit meetbeeld intrekken?",
+    "withdrawConfirm": "Het wordt met de bestanden verwijderd en geen andere werkplaats ziet het.",
+    "withdrawn": "Meetbeeld ingetrokken",
+    "withdrawFailed": "Het meetbeeld kon nu niet worden ingetrokken. Probeer het zo opnieuw.",
+    "shareDialog": {
+      "title": "Meetbeeld delen voor {code}",
+      "description": "Andere werkplaatsen zien het zodra iemand van Torqvoice het heeft gecontroleerd.",
+      "meaningHint": "Deze code betekent iets anders in andere regeleenheden. Je meetbeeld wordt alleen voor deze betekenis gedeeld.",
+      "kind": "Wat laat het zien?",
+      "kindHint": {
+        "good": "Hoe het eruitziet bij een gezonde auto",
+        "goodMarine": "Hoe het eruitziet bij een gezonde motor",
+        "faulty": "Hoe het eruitzag met de storing",
+        "faultyMarine": "Hoe het eruitzag met de storing"
+      },
+      "measured": "Wat heb je gemeten?",
+      "measuredPlaceholder": "Krukassensor en inlaatnokkenas",
+      "measurements": {
+        "crank": "Krukassensor",
+        "camCrank": "Nokkenas en krukas",
+        "injector": "Injector",
+        "ignition": "Bobine",
+        "compression": "Relatieve compressie",
+        "lambda": "Lambdasonde",
+        "maf": "Luchtmassameter",
+        "alternator": "Rimpel van de dynamo",
+        "canBus": "CAN-bus",
+        "fuelPump": "Stroom van de brandstofpomp"
+      },
+      "condition": "Hoe liep de motor? (optioneel)",
+      "files": "Bestanden",
+      "filesHint": "Eén is genoeg. Met het PicoScope-bestand openen anderen het in PicoScope; met een CSV-export of foto zien ze het meteen hier.",
+      "phone": {
+        "button": "Foto van telefoon",
+        "title": "Maak een foto van het scopescherm",
+        "body": "Scan de code met de camera van je telefoon en maak één foto van het scherm. Hij verschijnt hier zodra hij binnen is.",
+        "received": "De foto van je telefoon is binnen"
+      },
+      "drop": "Zet je PicoScope-bestand hier neer, of klik om te kiezen",
+      "dropMore": "Nog een bestand toevoegen",
+      "dropTypes": ".psdata, .pssettings, een CSV-export of een foto van het scherm",
+      "role": {
+        "psdata": "PicoScope-opname",
+        "csv": "CSV-export",
+        "image": "Foto van het scherm",
+        "pssettings": "Scope-instellingen"
+      },
+      "csvNotScope": "Dit is geen scope-export. Sla hem opnieuw op vanuit PicoScope als CSV.",
+      "remove": "{name} verwijderen",
+      "fromPhone": "Van je telefoon",
+      "previewHint": "Zo zien andere werkplaatsen het.",
+      "csvTip": "Tip: voeg ook een CSV-export toe, zodat anderen het meetbeeld hier zien zonder PicoScope te openen. Kies in PicoScope Opslaan als en dan CSV.",
+      "note": "Opmerkingen (optioneel)",
+      "notePlaceholder": "Waar je hebt gemeten, welk bereik je gebruikte en waar je op moet letten. Bijvoorbeeld: pin 2 achter in de sensorstekker gemeten, de opening van de ontbrekende tanden komt één keer per omwenteling.",
+      "notePlaceholderMarine": "Waar je hebt gemeten, welk bereik je gebruikte en waar je op moet letten.",
+      "sending": "Versturen {percent}%",
+      "send": "Meetbeeld delen",
+      "sent": "Bedankt. Je meetbeeld wacht op controle.",
+      "missing": {
+        "kind": "Kies goed of defect",
+        "title": "Geef aan wat je hebt gemeten",
+        "file": "Voeg een bestand of foto toe",
+        "csv": "Verwijder de CSV die geen scope-export is"
+      },
+      "notAScopeFile": "{name} is geen PicoScope-bestand, CSV-export of foto",
+      "fileTooBig": "{name} is groter dan {size}",
+      "errors": {
+        "failed": "Je meetbeeld kon nu niet worden verstuurd. Probeer het zo opnieuw.",
+        "photo": "De foto kon niet worden gelezen. Probeer een andere.",
+        "csv": "De CSV is geen scope-export. Sla hem opnieuw op vanuit PicoScope als CSV.",
+        "too_large": "De bestanden zijn te groot.",
+        "no_file": "Voeg de opname, een CSV-export of een foto toe.",
+        "paused": "Meetbeelden delen is nu gepauzeerd. Probeer het later opnieuw.",
+        "demo": "Meetbeelden delen staat uit in de demo."
+      }
+    }
+  },
+  "improve": {
+    "button": "Help ons verbeteren",
+    "title": "Help ons de foutcodes te verbeteren",
+    "description": "Weet je waar een merk zijn eigen codes publiceert, zag je een fout of heb je een idee? Laat het ons weten. Het gaat rechtstreeks naar het Torqvoice-team.",
+    "topic": "Waar gaat het over? (optioneel)",
+    "topics": {
+      "makes": "Codes die ontbreken",
+      "wrong": "Er klopt iets niet",
+      "source": "Een goede bron of link",
+      "idea": "Een idee"
+    },
+    "message": "Je bericht",
+    "placeholders": {
+      "makes": "Waar ze te vinden zijn, bijvoorbeeld een link naar de codelijst van het merk, en voor welke modellen die geldt.",
+      "wrong": "Welke code, wat er staat en wat er zou moeten staan. Een link naar waar je het vond helpt.",
+      "source": "Plak de link en zeg wat erin staat.",
+      "idea": "Wat zou de foutcodes nuttiger maken in jouw werkplaats? Links zijn welkom."
+    },
+    "links": "Links in je bericht",
+    "about": "Over {code}",
+    "leaveOut": "Weglaten",
+    "replyHint": "We antwoorden naar het e-mailadres van je account.",
+    "cancel": "Annuleren",
+    "send": "Versturen",
+    "sent": "Bedankt. Je bericht is onderweg naar het Torqvoice-team.",
+    "errors": {
+      "failed": "Je bericht kon nu niet worden verstuurd. Probeer het zo opnieuw.",
+      "limited": "Je hebt al een paar berichten gestuurd. Probeer het over een uur opnieuw."
+    }
+  },
+  "import": {
+    "button": "Importeren",
+    "title": "Foutcodes importeren",
+    "description": "Voeg de eigen codes van een merk toe, of meer over codes die we hebben. Iemand van Torqvoice controleert elke code voordat een werkplaats hem ziet.",
+    "yours": "Je imports",
+    "codes": "{count, plural, one {# code} other {# codes}}",
+    "status": {
+      "published": "{count} gepubliceerd",
+      "pending": "{count} wachten",
+      "rejected": "{count} niet gepubliceerd"
+    },
+    "examples": {
+      "title": "Begin met een voorbeeld",
+      "sheet": "Voorbeeldspreadsheet",
+      "sheetHint": "Excel, één rij per code",
+      "json": "Voorbeeld-JSON",
+      "jsonHint": "Elk veld benoemd",
+      "columns": "Houd de kolomnamen aan: Code, Title, Description, Common issues, How to fix, Good to know en Causes. Alleen Code en Title zijn nodig. Zet elke oorzaak op een eigen regel, met (high), (medium) of (low) erachter als je het weet.",
+      "csv": "Download als CSV"
+    },
+    "make": "Merk",
+    "pickMake": "Kies een merk",
+    "language": "Taal van de tekst",
+    "source": "Waar het vandaan komt (optioneel)",
+    "sourcePlaceholder": "Link of boek",
+    "checking": "Je codes worden gelezen en gecontroleerd",
+    "removeFile": "Bestand verwijderen",
+    "drop": "Zet je bestand hier neer, of klik om te kiezen",
+    "dropTypes": ".xlsx, .csv of .json, tot 2.000 codes",
+    "fileProblems": {
+      "unreadable": "Het bestand kon niet worden gelezen. Sla het opnieuw op als .xlsx, .csv of .json.",
+      "empty": "Het bestand bevat geen codes.",
+      "no_code_column": "Geen kolom Code gevonden. Begin met de voorbeeldspreadsheet.",
+      "too_many": "Het bestand is te groot: hooguit 2.000 codes en 5 MB. Splits het op.",
+      "format": "Dit bestandstype wordt niet ondersteund. Gebruik .xlsx, .csv of .json.",
+      "legacy_xls": "Oude .xls-bestanden worden niet ondersteund. Sla het in Excel op als .xlsx."
+    },
+    "counts": {
+      "new": "{count} nieuw",
+      "change": "{count} vullen de onze aan",
+      "problem": "{count} moeten worden verbeterd",
+      "same": "{count} al hetzelfde"
+    },
+    "rows": "Codes in het bestand",
+    "row": "Rij {n}",
+    "more": "en nog {count}",
+    "problemsLeftOut": "Codes die verbeterd moeten worden, worden weggelaten. Verbeter ze in het bestand en zet het opnieuw neer, of stuur de rest nu.",
+    "rights": "Ik mag dit delen. Het is niet gekopieerd uit een betaalde of beschermde bron.",
+    "missing": {
+      "make": "Kies eerst het merk",
+      "file": "Voeg je bestand toe",
+      "nothing": "Niets in het bestand vult aan wat we hebben",
+      "rights": "Vink het vakje aan om te versturen"
+    },
+    "reviewHint": "Niets wordt gepubliceerd voordat iemand het heeft gecontroleerd.",
+    "cancel": "Annuleren",
+    "send": "{count, plural, one {# code ter controle sturen} other {# codes ter controle sturen}}",
+    "sendNone": "Ter controle sturen",
+    "sent": "{count, plural, one {Verstuurd. # code wacht op controle.} other {Verstuurd. # codes wachten op controle.}}",
+    "errors": {
+      "failed": "Je bestand kon nu niet worden gecontroleerd of verstuurd. Probeer het zo opnieuw.",
+      "paused": "Codes versturen is nu gepauzeerd. Probeer het later opnieuw.",
+      "nothing": "Niets in het bestand vult aan wat we hebben.",
+      "demo": "Codes importeren staat uit in de demo.",
+      "rights": "Vink aan dat je dit mag delen.",
+      "too_large": "Het bestand is te groot: hooguit 2.000 codes en 5 MB."
+    },
+    "problems": {
+      "code": "Geen code die {make} gebruikt",
+      "duplicate": "Dezelfde code twee keer in het bestand",
+      "title": "Heeft een titel nodig",
+      "empty": "Er staat niets bij",
+      "too_long": "Te lang: een titel tot 200 tekens, elke tekst tot 4.000",
+      "likelihood": "De waarschijnlijkheid moet low, medium of high zijn",
+      "meanings": "{make} gebruikt deze code voor meer dan één ding. Deel het liever op de pagina van de code."
+    },
+    "state": {
+      "new": "Nieuw",
+      "change": "Voegt {what} toe"
+    },
+    "fields": {
+      "title": "titel",
+      "description": "betekenis",
+      "common_issues": "veelvoorkomende problemen",
+      "resolution": "hoe te verhelpen",
+      "advisory": "goed om te weten",
+      "causes": "oorzaken"
+    },
+    "addsCauses": "{count, plural, one {# oorzaak} other {# oorzaken}}",
+    "searchMake": "Merken zoeken",
+    "noMake": "Geen merk gevonden",
+    "useMake": "{name} gebruiken",
+    "groups": {
+      "cars": "Auto’s en bestelwagens",
+      "marine": "Boten en motoren"
+    }
+  },
+  "makeCodes": {
+    "heading": "Merken met een eigen betekenis voor {code}",
+    "thisCar": "Merk van dit voertuig"
   }
   }
 }
 }

+ 351 - 3
messages/pl/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Dane producentów",
     "manufacturer": "Dane producentów",
     "related": "Razem z innymi kodami",
     "related": "Razem z innymi kodami",
     "diagnosis": "Wskazówka diagnostyczna",
     "diagnosis": "Wskazówka diagnostyczna",
+    "common_issues": "Częste problemy",
+    "resolution": "Rozwiązanie",
+    "advisory": "Zalecenie",
     "other": "Warto wiedzieć"
     "other": "Warto wiedzieć"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Model",
     "model": "Model",
     "year": "Rok",
     "year": "Rok",
     "vehicleHint": "Udostępniane są tylko marka, model, rok i silnik, nigdy tablica rejestracyjna, VIN, klient ani nazwa twojego warsztatu.",
     "vehicleHint": "Udostępniane są tylko marka, model, rok i silnik, nigdy tablica rejestracyjna, VIN, klient ani nazwa twojego warsztatu.",
+    "meaning": "Znaczenie",
+    "meaningHint": "W innych sterownikach ten kod znaczy co innego. Twoje rozwiązanie jest udostępniane tylko dla tego znaczenia.",
     "vehicleHintMarine": "Udostępniane są tylko marka, model, rok i silnik, nigdy numer rejestracyjny, numer kadłuba, klient ani nazwa twojego warsztatu.",
     "vehicleHintMarine": "Udostępniane są tylko marka, model, rok i silnik, nigdy numer rejestracyjny, numer kadłuba, klient ani nazwa twojego warsztatu.",
     "send": "Udostępnij rozwiązanie",
     "send": "Udostępnij rozwiązanie",
     "sent": "Dziękujemy. Twoje rozwiązanie czeka na sprawdzenie.",
     "sent": "Dziękujemy. Twoje rozwiązanie czeka na sprawdzenie.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Dodaj kody} one {Dodaj # kod} few {Dodaj # kody} many {Dodaj # kodów} other {Dodaj # kodu}}",
     "add": "{count, plural, =0 {Dodaj kody} one {Dodaj # kod} few {Dodaj # kody} many {Dodaj # kodów} other {Dodaj # kodu}}",
     "added": "{count, plural, one {Dodano # kod usterki} few {Dodano # kody usterek} many {Dodano # kodów usterek} other {Dodano # kodu usterek}}",
     "added": "{count, plural, one {Dodano # kod usterki} few {Dodano # kody usterek} many {Dodano # kodów usterek} other {Dodano # kodu usterek}}",
     "skipped": "{count, plural, one {# kod był już w tym zleceniu} few {# kody były już w tym zleceniu} many {# kodów było już w tym zleceniu} other {# kodu było już w tym zleceniu}}",
     "skipped": "{count, plural, one {# kod był już w tym zleceniu} few {# kody były już w tym zleceniu} many {# kodów było już w tym zleceniu} other {# kodu było już w tym zleceniu}}",
-    "failed": "Nie udało się dodać kodów"
+    "failed": "Nie udało się dodać kodów",
+    "descriptionOem": "Wpisz lub wklej kody z testera diagnostycznego: kody OBD, np. P0420, lub kody BMW, np. 2A82. Każdy staje się obserwacją w tym zleceniu z zapisanym znaczeniem.",
+    "placeholderOem": "P0420, 2A82 zapisany"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Widziany {date}",
     "seenBefore": "Widziany {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Kody usterek to nowość",
     "title": "Kody usterek to nowość",
-    "body": "Stale dodajemy kody, więc nie ma tu jeszcze wszystkich. Na razie obejmuje to standardowe kody OBD-II; kodów specyficznych dla producentów jeszcze nie ma."
+    "body": "Obejmuje to standardowe kody OBD-II. Pracujemy nad dodaniem własnych kodów producentów, a nowe kody przybywają cały czas. Wiesz, gdzie znaleźć kody danej marki? Daj nam znać przez „Pomóż nam się poprawić”.",
+    "bodyBmw": "Obejmuje to standardowe kody OBD-II i własne kody BMW, wspólne z MINI i Rolls-Royce. Pracujemy nad dodaniem kolejnych marek, a nowe kody przybywają cały czas. Wiesz, gdzie znaleźć kody danej marki? Daj nam znać przez „Pomóż nam się poprawić”.",
+    "bodyMakes": "Obejmuje to standardowe kody OBD-II i własne kody marek: {makes}. Pracujemy nad dodaniem kolejnych marek, a nowe kody przybywają cały czas. Wiesz, gdzie znaleźć kody danej marki? Daj nam znać przez „Pomóż nam się poprawić”."
   },
   },
   "support": {
   "support": {
     "button": "Kontakt z pomocą",
     "button": "Kontakt z pomocą",
@@ -245,6 +254,345 @@
     "done": "Po zakończeniu wróć do komputera i udostępnij rozwiązanie.",
     "done": "Po zakończeniu wróć do komputera i udostępnij rozwiązanie.",
     "privacy": "Nie pokazuj na zdjęciach twarzy, tablic rejestracyjnych ani danych klientów.",
     "privacy": "Nie pokazuj na zdjęciach twarzy, tablic rejestracyjnych ani danych klientów.",
     "expired": "Ten kod wygasł. Wyświetl nowy na komputerze.",
     "expired": "Ten kod wygasł. Wyświetl nowy na komputerze.",
-    "invalid": "Ten kod nie działa. Wyświetl nowy na komputerze."
+    "invalid": "Ten kod nie działa. Wyświetl nowy na komputerze.",
+    "waveform": {
+      "title": "Zdjęcie ekranu oscyloskopu",
+      "forCode": "do przebiegu, który udostępniasz na komputerze",
+      "intro": "Zrób lub wybierz jedno zdjęcie ekranu oscyloskopu. Pojawi się na komputerze.",
+      "full": "Przebieg ma jedno zdjęcie. Usuń je na komputerze, aby zrobić kolejne.",
+      "done": "Wróć do komputera i udostępnij przebieg.",
+      "added": "Zdjęcie dodane"
+    }
+  },
+  "oem": {
+    "badge": "Kod {make}",
+    "summary": "{count, plural, one {# znaczenie} few {# znaczenia} many {# znaczeń} other {# znaczenia}}. Kod BMW może znaczyć co innego w każdym sterowniku.",
+    "matches": "Pasuje do tego auta",
+    "noMatch": "Żadne z tych znaczeń nie jest podane dla silnika tego auta. Sprawdź, z którego sterownika tester odczytał kod.",
+    "inEnglish": "Angielski tekst {make}: {text}",
+    "bmwText": "Tekst {make}: {text}",
+    "units": "Sterowniki",
+    "moreUnits": "+{count} więcej",
+    "otherChassis": "Inne nadwozia",
+    "years": "{from}–{to}",
+    "yearsFrom": "od {from}",
+    "source": "Znaczenia pochodzą z tekstów diagnostycznych BMW, w większości po niemiecku.",
+    "notServed": "Kody {make} nie są jeszcze dostępne.",
+    "notFound": "{code} nie ma w danych {make}.",
+    "whereUsed": "Gdzie {make} go używa",
+    "models": "Modele",
+    "engineList": "Silniki",
+    "fewerUnits": "Pokaż mniej",
+    "fitsThisCar": "Ten pojazd",
+    "meanings": "Znaczenia",
+    "otherMeanings": "i {count, plural, one {# inne znaczenie} few {# inne znaczenia} many {# innych znaczeń} other {# innych znaczeń}}"
+  },
+  "research": {
+    "inviteTitle": "Wyszukaj rozwiązanie w sieci",
+    "invite": "Szukamy {code} w dokumentacji producenta, biuletynach serwisowych i forach warsztatów. Zapisujemy tylko konkretną odpowiedź ze źródłami, a osoba z Torqvoice ją sprawdza.",
+    "button": "Wyszukaj rozwiązanie",
+    "working": "Wyszukiwanie dla {code}",
+    "steps": {
+      "bulletins": "Przeszukiwanie dokumentacji producenta i biuletynów serwisowych",
+      "forums": "Czytanie forów warsztatów z potwierdzonymi rozwiązaniami",
+      "agree": "Sprawdzanie, czy źródła są zgodne",
+      "writing": "Spisywanie kroków"
+    },
+    "workingHint": "To trwa minutę lub dwie. Co zostanie znalezione, zostaje przy kodzie dla wszystkich warsztatów.",
+    "runningTitle": "Trwa wyszukiwanie",
+    "runningHint": "Inny warsztat właśnie o to poprosił. Sprawdź ponownie za minutę.",
+    "checkAgain": "Sprawdź ponownie",
+    "title": "Znalezione rozwiązanie",
+    "pending": "Czeka na sprawdzenie przez osobę",
+    "published": "Sprawdzone przez Torqvoice",
+    "pendingHint": "Znalezione przez AI w źródłach poniżej. Sprawdź na pojeździe, zanim wymienisz części.",
+    "sources": "Źródła",
+    "helpfulQuestion": "Czy to pomogło?",
+    "helpfulYes": "Tak, pomogło",
+    "helpfulNo": "Nie, nie pomogło",
+    "thanks": "Dzięki, zapisane",
+    "missedTitle": "Nie znaleziono wiarygodnego rozwiązania w sieci",
+    "missed": "Nie znaleziono nic konkretnego o {code}, więc nic nie zapisano. Odnotowaliśmy, że warsztaty potrzebują tego kodu.",
+    "searchedOn": "Wyszukano {date}",
+    "shareInstead": "Udostępnij swoje rozwiązanie",
+    "errors": {
+      "busy": "Ten kod jest właśnie wyszukiwany. Sprawdź ponownie za minutę.",
+      "limited": "Twój warsztat wykorzystał dzisiejsze wyszukiwania. Spróbuj jutro.",
+      "failed": "Wyszukiwanie nie mogło się teraz odbyć. Spróbuj później."
+    }
+  },
+  "waveforms": {
+    "title": "Przebiegi",
+    "description": "Zapisy z oscyloskopu z innych warsztatów, prawidłowe i wadliwe, do porównania z Twoim pomiarem.",
+    "share": "Udostępnij przebieg",
+    "unavailable": "Przebiegi są teraz niedostępne.",
+    "empty": "Brak jeszcze przebiegów dla {code}",
+    "emptyHint": "Masz prawidłowy przebieg? Udostępnij go, a następny mechanik szukający tego kodu będzie miał z czym porównać.",
+    "filter": {
+      "label": "Pokaż",
+      "all": "Wszystkie",
+      "good": "Prawidłowy",
+      "faulty": "Wadliwy"
+    },
+    "unknownVehicle": "Pojazd nie podany",
+    "unknownVehicleMarine": "Łódź nie podana",
+    "status": {
+      "pending": "Czeka na sprawdzenie",
+      "published": "Opublikowany",
+      "rejected": "Nieopublikowany"
+    },
+    "hasRecording": "Plik PicoScope do pobrania",
+    "hasCsv": "Eksport CSV do pobrania",
+    "kind": {
+      "good": "Prawidłowy",
+      "faulty": "Wadliwy"
+    },
+    "condition": {
+      "key_on": "Zapłon włączony, silnik wyłączony",
+      "cranking": "Rozruch",
+      "idle": "Bieg jałowy",
+      "rpm_2500": "2500 obr./min",
+      "driving": "Podczas jazdy",
+      "drivingMarine": "Podczas pływania",
+      "under_load": "Pod obciążeniem"
+    },
+    "chart": {
+      "label": "Przebieg {channels} w czasie {span}",
+      "perDivision": "{value}/dz.",
+      "noSample": "brak próbki",
+      "between": "od {lo} do {hi}"
+    },
+    "view": {
+      "label": "Pokaż",
+      "trace": "Przebieg",
+      "picture": "Zdjęcie ekranu"
+    },
+    "sharedOn": "Udostępniono {date}",
+    "reason": "Powód: {reason}",
+    "compare": {
+      "label": "Porównaj z",
+      "none": "Niczym",
+      "close": "Zakończ porównanie"
+    },
+    "note": "Uwagi",
+    "writtenIn": "Napisano w języku: {language}",
+    "files": {
+      "title": "Pliki",
+      "psdata": "Zapis PicoScope",
+      "csv": "Eksport CSV",
+      "pssettings": "Ustawienia oscyloskopu",
+      "openHint": "Otwórz zapis w PicoScope, aby powiększyć, zmierzyć i zobaczyć każdy kanał."
+    },
+    "nothingToDraw": "Udostępniono tylko zapis. Pobierz go i otwórz w PicoScope.",
+    "openPicture": "Otwórz zdjęcie w pełnym rozmiarze",
+    "close": "Zamknij",
+    "withdraw": "Wycofaj",
+    "withdrawTitle": "Wycofać ten przebieg?",
+    "withdrawConfirm": "Zostanie usunięty razem z plikami i żaden inny warsztat go nie zobaczy.",
+    "withdrawn": "Przebieg wycofany",
+    "withdrawFailed": "Nie udało się teraz wycofać przebiegu. Spróbuj ponownie za chwilę.",
+    "shareDialog": {
+      "title": "Udostępnij przebieg dla {code}",
+      "description": "Inne warsztaty zobaczą go, gdy sprawdzi go osoba z Torqvoice.",
+      "meaningHint": "W innych sterownikach ten kod oznacza coś innego. Twój przebieg jest udostępniany tylko dla tego znaczenia.",
+      "kind": "Co pokazuje?",
+      "kindHint": {
+        "good": "Jak wygląda w sprawnym samochodzie",
+        "goodMarine": "Jak wygląda w sprawnym silniku",
+        "faulty": "Jak wyglądał przy usterce",
+        "faultyMarine": "Jak wyglądał przy usterce"
+      },
+      "measured": "Co mierzyłeś?",
+      "measuredPlaceholder": "Czujnik wału korbowego i wałek rozrządu dolotu",
+      "measurements": {
+        "crank": "Czujnik wału korbowego",
+        "camCrank": "Wałek rozrządu i wał korbowy",
+        "injector": "Wtryskiwacz",
+        "ignition": "Cewka zapłonowa",
+        "compression": "Kompresja względna",
+        "lambda": "Sonda lambda",
+        "maf": "Przepływomierz powietrza",
+        "alternator": "Tętnienia alternatora",
+        "canBus": "Magistrala CAN",
+        "fuelPump": "Prąd pompy paliwa"
+      },
+      "condition": "Jak pracował silnik? (opcjonalnie)",
+      "files": "Pliki",
+      "filesHint": "Wystarczy jeden. Plik PicoScope inni otworzą w PicoScope; eksport CSV lub zdjęcie zobaczą od razu tutaj.",
+      "phone": {
+        "button": "Zdjęcie z telefonu",
+        "title": "Zrób zdjęcie ekranu oscyloskopu",
+        "body": "Zeskanuj kod aparatem telefonu i zrób jedno zdjęcie ekranu. Pojawi się tutaj, gdy dotrze.",
+        "received": "Zdjęcie z Twojego telefonu dotarło"
+      },
+      "drop": "Upuść tutaj plik PicoScope lub kliknij, aby wybrać",
+      "dropMore": "Dodaj kolejny plik",
+      "dropTypes": ".psdata, .pssettings, eksport CSV lub zdjęcie ekranu",
+      "role": {
+        "psdata": "Zapis PicoScope",
+        "csv": "Eksport CSV",
+        "image": "Zdjęcie ekranu",
+        "pssettings": "Ustawienia oscyloskopu"
+      },
+      "csvNotScope": "To nie jest eksport z oscyloskopu. Zapisz go ponownie z PicoScope jako CSV.",
+      "remove": "Usuń {name}",
+      "fromPhone": "Z Twojego telefonu",
+      "previewHint": "Tak zobaczą go inne warsztaty.",
+      "csvTip": "Wskazówka: dodaj też eksport CSV, aby inni widzieli przebieg tutaj bez otwierania PicoScope. W PicoScope wybierz Zapisz jako i CSV.",
+      "note": "Uwagi (opcjonalnie)",
+      "notePlaceholder": "Gdzie mierzyłeś, jaki zakres ustawiłeś i na co zwrócić uwagę. Na przykład: pin 2 od tyłu wtyczki czujnika, przerwa po brakujących zębach pojawia się raz na obrót.",
+      "notePlaceholderMarine": "Gdzie mierzyłeś, jaki zakres ustawiłeś i na co zwrócić uwagę.",
+      "sending": "Wysyłanie {percent}%",
+      "send": "Udostępnij przebieg",
+      "sent": "Dziękujemy. Twój przebieg czeka na sprawdzenie.",
+      "missing": {
+        "kind": "Wybierz prawidłowy lub wadliwy",
+        "title": "Napisz, co mierzyłeś",
+        "file": "Dodaj plik lub zdjęcie",
+        "csv": "Usuń plik CSV, który nie jest eksportem z oscyloskopu"
+      },
+      "notAScopeFile": "{name} nie jest plikiem PicoScope, eksportem CSV ani zdjęciem",
+      "fileTooBig": "{name} jest większy niż {size}",
+      "errors": {
+        "failed": "Nie udało się teraz wysłać Twojego przebiegu. Spróbuj ponownie za chwilę.",
+        "photo": "Nie udało się odczytać zdjęcia. Spróbuj innego.",
+        "csv": "Plik CSV nie jest eksportem z oscyloskopu. Zapisz go ponownie z PicoScope jako CSV.",
+        "too_large": "Pliki są za duże.",
+        "no_file": "Dodaj zapis, eksport CSV lub zdjęcie.",
+        "paused": "Udostępnianie przebiegów jest teraz wstrzymane. Spróbuj później.",
+        "demo": "Udostępnianie przebiegów jest wyłączone w wersji demo."
+      }
+    }
+  },
+  "improve": {
+    "button": "Pomóż nam się poprawić",
+    "title": "Pomóż nam ulepszyć kody usterek",
+    "description": "Wiesz, gdzie marka publikuje swoje kody, zauważyłeś błąd albo masz pomysł? Napisz do nas. Wiadomość trafia prosto do zespołu Torqvoice.",
+    "topic": "Czego to dotyczy? (opcjonalnie)",
+    "topics": {
+      "makes": "Brakujące kody",
+      "wrong": "Coś się nie zgadza",
+      "source": "Dobre źródło lub link",
+      "idea": "Pomysł"
+    },
+    "message": "Twoja wiadomość",
+    "placeholders": {
+      "makes": "Gdzie je znaleźć, na przykład link do listy kodów marki, i jakich modeli dotyczy.",
+      "wrong": "Który kod, co jest napisane i co powinno być. Link do źródła pomoże.",
+      "source": "Wklej link i napisz, co zawiera.",
+      "idea": "Co sprawiłoby, że kody usterek byłyby bardziej przydatne w Twoim warsztacie? Linki mile widziane."
+    },
+    "links": "Linki w Twojej wiadomości",
+    "about": "Dotyczy {code}",
+    "leaveOut": "Pomiń",
+    "replyHint": "Odpowiemy na adres e-mail Twojego konta.",
+    "cancel": "Anuluj",
+    "send": "Wyślij",
+    "sent": "Dziękujemy. Twoja wiadomość jest w drodze do zespołu Torqvoice.",
+    "errors": {
+      "failed": "Nie udało się teraz wysłać wiadomości. Spróbuj ponownie za chwilę.",
+      "limited": "Wysłałeś już kilka wiadomości. Spróbuj ponownie za godzinę."
+    }
+  },
+  "import": {
+    "button": "Importuj",
+    "title": "Importuj kody usterek",
+    "description": "Dodaj własne kody marki albo więcej o kodach, które mamy. Osoba z Torqvoice sprawdza każdy kod, zanim zobaczy go jakikolwiek warsztat.",
+    "yours": "Twoje importy",
+    "codes": "{count, plural, one {# kod} few {# kody} many {# kodów} other {# kodu}}",
+    "status": {
+      "published": "opublikowane: {count}",
+      "pending": "czekają: {count}",
+      "rejected": "nieopublikowane: {count}"
+    },
+    "examples": {
+      "title": "Zacznij od przykładu",
+      "sheet": "Przykładowy arkusz",
+      "sheetHint": "Excel, jeden wiersz na kod",
+      "json": "Przykładowy JSON",
+      "jsonHint": "Każde pole nazwane",
+      "columns": "Zachowaj nazwy kolumn: Code, Title, Description, Common issues, How to fix, Good to know i Causes. Potrzebne są tylko Code i Title. Każdą przyczynę wpisz w osobnym wierszu, z (high), (medium) lub (low) na końcu, jeśli wiesz.",
+      "csv": "Pobierz jako CSV"
+    },
+    "make": "Marka",
+    "pickMake": "Wybierz markę",
+    "language": "Język tekstu",
+    "source": "Skąd to pochodzi (opcjonalnie)",
+    "sourcePlaceholder": "Link lub książka",
+    "checking": "Odczytywanie i sprawdzanie Twoich kodów",
+    "removeFile": "Usuń plik",
+    "drop": "Upuść tutaj plik lub kliknij, aby wybrać",
+    "dropTypes": ".xlsx, .csv lub .json, do 2000 kodów",
+    "fileProblems": {
+      "unreadable": "Nie udało się odczytać pliku. Zapisz go ponownie jako .xlsx, .csv lub .json.",
+      "empty": "Plik nie zawiera kodów.",
+      "no_code_column": "Nie znaleziono kolumny Code. Zacznij od przykładowego arkusza.",
+      "too_many": "Plik jest za duży: najwyżej 2000 kodów i 5 MB. Podziel go na części.",
+      "format": "Ten typ pliku nie jest obsługiwany. Użyj .xlsx, .csv lub .json.",
+      "legacy_xls": "Stare pliki .xls nie są obsługiwane. Zapisz go w Excelu jako .xlsx."
+    },
+    "counts": {
+      "new": "nowe: {count}",
+      "change": "uzupełniają nasze: {count}",
+      "problem": "do poprawy: {count}",
+      "same": "już takie same: {count}"
+    },
+    "rows": "Kody w pliku",
+    "row": "Wiersz {n}",
+    "more": "i jeszcze {count}",
+    "problemsLeftOut": "Kody do poprawy są pomijane. Popraw je w pliku i upuść go ponownie albo wyślij teraz resztę.",
+    "rights": "Mogę to udostępnić. Nie jest to skopiowane z płatnego lub chronionego źródła.",
+    "missing": {
+      "make": "Najpierw wybierz markę",
+      "file": "Dodaj swój plik",
+      "nothing": "Nic w pliku nie uzupełnia tego, co mamy",
+      "rights": "Zaznacz pole, aby wysłać"
+    },
+    "reviewHint": "Nic nie jest publikowane, zanim nie sprawdzi tego człowiek.",
+    "cancel": "Anuluj",
+    "send": "{count, plural, one {Wyślij # kod do sprawdzenia} few {Wyślij # kody do sprawdzenia} many {Wyślij # kodów do sprawdzenia} other {Wyślij # kodu do sprawdzenia}}",
+    "sendNone": "Wyślij do sprawdzenia",
+    "sent": "{count, plural, one {Wysłano. # kod czeka na sprawdzenie.} few {Wysłano. # kody czekają na sprawdzenie.} many {Wysłano. # kodów czeka na sprawdzenie.} other {Wysłano. # kodu czeka na sprawdzenie.}}",
+    "errors": {
+      "failed": "Nie udało się teraz sprawdzić ani wysłać pliku. Spróbuj ponownie za chwilę.",
+      "paused": "Wysyłanie kodów jest teraz wstrzymane. Spróbuj później.",
+      "nothing": "Nic w pliku nie uzupełnia tego, co mamy.",
+      "demo": "Import kodów jest wyłączony w wersji demo.",
+      "rights": "Zaznacz pole, że możesz to udostępnić.",
+      "too_large": "Plik jest za duży: najwyżej 2000 kodów i 5 MB."
+    },
+    "problems": {
+      "code": "To nie jest kod używany przez {make}",
+      "duplicate": "Ten sam kod dwa razy w pliku",
+      "title": "Potrzebny tytuł",
+      "empty": "Nic nie wpisano",
+      "too_long": "Za długie: tytuł do 200 znaków, każdy tekst do 4000",
+      "likelihood": "Prawdopodobieństwo musi być low, medium lub high",
+      "meanings": "{make} używa tego kodu do więcej niż jednej rzeczy. Udostępnij to raczej na stronie kodu."
+    },
+    "state": {
+      "new": "Nowy",
+      "change": "Dodaje: {what}"
+    },
+    "fields": {
+      "title": "tytuł",
+      "description": "znaczenie",
+      "common_issues": "częste problemy",
+      "resolution": "jak naprawić",
+      "advisory": "warto wiedzieć",
+      "causes": "przyczyny"
+    },
+    "addsCauses": "{count, plural, one {# przyczyna} few {# przyczyny} many {# przyczyn} other {# przyczyny}}",
+    "searchMake": "Szukaj marek",
+    "noMake": "Nie znaleziono marki",
+    "useMake": "Użyj {name}",
+    "groups": {
+      "cars": "Samochody i dostawczaki",
+      "marine": "Łodzie i silniki"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marki z własnym znaczeniem dla {code}",
+    "thisCar": "Marka tego pojazdu"
   }
   }
 }
 }

+ 351 - 3
messages/pt-BR/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Dados dos fabricantes",
     "manufacturer": "Dados dos fabricantes",
     "related": "Junto com outros códigos",
     "related": "Junto com outros códigos",
     "diagnosis": "Dica de diagnóstico",
     "diagnosis": "Dica de diagnóstico",
+    "common_issues": "Problemas comuns",
+    "resolution": "Solução",
+    "advisory": "Recomendação",
     "other": "Bom saber"
     "other": "Bom saber"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modelo",
     "model": "Modelo",
     "year": "Ano",
     "year": "Ano",
     "vehicleHint": "Só a marca, o modelo, o ano e o motor são compartilhados, nunca a placa, o chassi (VIN), o cliente ou o nome da sua oficina.",
     "vehicleHint": "Só a marca, o modelo, o ano e o motor são compartilhados, nunca a placa, o chassi (VIN), o cliente ou o nome da sua oficina.",
+    "meaning": "Significado",
+    "meaningHint": "Este código significa outra coisa em outros módulos. Sua solução é compartilhada só para este significado.",
     "vehicleHintMarine": "Só a marca, o modelo, o ano e o motor são compartilhados, nunca a inscrição, o número do casco, o cliente ou o nome da sua oficina.",
     "vehicleHintMarine": "Só a marca, o modelo, o ano e o motor são compartilhados, nunca a inscrição, o número do casco, o cliente ou o nome da sua oficina.",
     "send": "Compartilhar solução",
     "send": "Compartilhar solução",
     "sent": "Obrigado. Sua solução está aguardando revisão.",
     "sent": "Obrigado. Sua solução está aguardando revisão.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Adicionar códigos} one {Adicionar # código} other {Adicionar # códigos}}",
     "add": "{count, plural, =0 {Adicionar códigos} one {Adicionar # código} other {Adicionar # códigos}}",
     "added": "{count, plural, one {# código de falha adicionado} other {# códigos de falha adicionados}}",
     "added": "{count, plural, one {# código de falha adicionado} other {# códigos de falha adicionados}}",
     "skipped": "{count, plural, one {# código já estava neste serviço} other {# códigos já estavam neste serviço}}",
     "skipped": "{count, plural, one {# código já estava neste serviço} other {# códigos já estavam neste serviço}}",
-    "failed": "Não foi possível adicionar os códigos"
+    "failed": "Não foi possível adicionar os códigos",
+    "descriptionOem": "Digite ou cole os códigos do scanner, códigos OBD como P0420 ou códigos BMW como 2A82. Cada um vira uma observação neste serviço, com o significado salvo.",
+    "placeholderOem": "P0420, 2A82 armazenado"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Visto {date}",
     "seenBefore": "Visto {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Os códigos de falha são novos",
     "title": "Os códigos de falha são novos",
-    "body": "Estamos adicionando códigos o tempo todo, então nem todos estão aqui ainda. Por enquanto, estão incluídos os códigos OBD-II padrão; os códigos específicos de cada fabricante ainda não."
+    "body": "Estão incluídos os códigos OBD-II padrão. Estamos trabalhando para adicionar os códigos próprios das marcas, e chegam códigos novos o tempo todo. Sabe onde encontrar os códigos de uma marca? Conte para a gente em “Ajude-nos a melhorar”.",
+    "bodyBmw": "Estão incluídos os códigos OBD-II padrão e os códigos próprios da BMW, compartilhados por MINI e Rolls-Royce. Estamos trabalhando para adicionar mais marcas, e chegam códigos novos o tempo todo. Sabe onde encontrar os códigos de uma marca? Conte para a gente em “Ajude-nos a melhorar”.",
+    "bodyMakes": "Estão incluídos os códigos OBD-II padrão e os códigos próprios de {makes}. Estamos trabalhando para adicionar mais marcas, e chegam códigos novos o tempo todo. Sabe onde encontrar os códigos de uma marca? Conte para a gente em “Ajude-nos a melhorar”."
   },
   },
   "support": {
   "support": {
     "button": "Falar com o suporte",
     "button": "Falar com o suporte",
@@ -245,6 +254,345 @@
     "done": "Quando terminar, volte ao computador e compartilhe a solução.",
     "done": "Quando terminar, volte ao computador e compartilhe a solução.",
     "privacy": "Deixe rostos, placas e dados de clientes fora da imagem.",
     "privacy": "Deixe rostos, placas e dados de clientes fora da imagem.",
     "expired": "Este código expirou. Mostre um novo no computador.",
     "expired": "Este código expirou. Mostre um novo no computador.",
-    "invalid": "Este código não funciona. Mostre um novo no computador."
+    "invalid": "Este código não funciona. Mostre um novo no computador.",
+    "waveform": {
+      "title": "Foto da tela do osciloscópio",
+      "forCode": "para a forma de onda que você está compartilhando no computador",
+      "intro": "Tire ou escolha uma foto da tela do osciloscópio. Ela aparece no computador.",
+      "full": "Uma forma de onda tem uma foto. Remova-a no computador para tirar outra.",
+      "done": "Volte ao computador e compartilhe a forma de onda.",
+      "added": "Foto adicionada"
+    }
+  },
+  "oem": {
+    "badge": "Código {make}",
+    "summary": "{count, plural, one {# significado} other {# significados}}. Um código BMW pode significar algo diferente em cada módulo.",
+    "matches": "Combina com este carro",
+    "noMatch": "Nenhum destes significados aparece para o motor deste carro. Confira de qual módulo o scanner leu o código.",
+    "inEnglish": "Texto em inglês da {make}: {text}",
+    "bmwText": "Texto da {make}: {text}",
+    "units": "Módulos",
+    "moreUnits": "+{count} mais",
+    "otherChassis": "Outros chassis",
+    "years": "{from} a {to}",
+    "yearsFrom": "desde {from}",
+    "source": "Os significados vêm dos textos de diagnóstico da própria BMW, a maioria em alemão.",
+    "notServed": "Os códigos {make} ainda não estão disponíveis.",
+    "notFound": "{code} não está nos dados da {make}.",
+    "whereUsed": "Onde a {make} o usa",
+    "models": "Modelos",
+    "engineList": "Motores",
+    "fewerUnits": "Mostrar menos",
+    "fitsThisCar": "Este veículo",
+    "meanings": "Significados",
+    "otherMeanings": "e {count, plural, one {mais # significado} other {mais # significados}}"
+  },
+  "research": {
+    "inviteTitle": "Pesquisar uma solução online",
+    "invite": "Pesquisamos {code} em documentação do fabricante, boletins técnicos e fóruns de oficinas. Só uma resposta específica com fontes é guardada, e uma pessoa da Torqvoice a verifica.",
+    "button": "Pesquisar uma solução",
+    "working": "Pesquisando {code}",
+    "steps": {
+      "bulletins": "Pesquisando documentação do fabricante e boletins técnicos",
+      "forums": "Lendo fóruns de oficinas com soluções confirmadas",
+      "agree": "Verificando se as fontes concordam",
+      "writing": "Escrevendo os passos"
+    },
+    "workingHint": "Leva um ou dois minutos. O que for encontrado fica no código para todas as oficinas.",
+    "runningTitle": "Sendo pesquisado agora",
+    "runningHint": "Outra oficina pediu há pouco. Verifique de novo em um minuto.",
+    "checkAgain": "Verificar de novo",
+    "title": "Solução pesquisada",
+    "pending": "Aguardando verificação de uma pessoa",
+    "published": "Verificado pela Torqvoice",
+    "pendingHint": "Encontrado por IA a partir das fontes abaixo. Confira no veículo antes de trocar peças.",
+    "sources": "Fontes",
+    "helpfulQuestion": "Isso resolveu?",
+    "helpfulYes": "Sim, resolveu",
+    "helpfulNo": "Não resolveu",
+    "thanks": "Obrigado, anotado",
+    "missedTitle": "Nenhuma solução confiável encontrada online",
+    "missed": "Nada específico sobre {code} apareceu, então nada foi salvo. Anotamos que as oficinas precisam deste código.",
+    "searchedOn": "Pesquisado em {date}",
+    "shareInstead": "Compartilhe sua solução",
+    "errors": {
+      "busy": "Este código está sendo pesquisado agora. Verifique de novo em um minuto.",
+      "limited": "Sua oficina já usou as pesquisas de hoje. Tente amanhã.",
+      "failed": "A pesquisa não pôde ser feita agora. Tente mais tarde."
+    }
+  },
+  "waveforms": {
+    "title": "Formas de onda",
+    "description": "Gravações de osciloscópio de outras oficinas, boas e com defeito, para comparar com o que você mede.",
+    "share": "Compartilhar uma forma de onda",
+    "unavailable": "As formas de onda não estão disponíveis agora.",
+    "empty": "Ainda não há formas de onda para {code}",
+    "emptyHint": "Tem uma gravação boa? Compartilhe, e o próximo técnico atrás deste código terá com o que comparar.",
+    "filter": {
+      "label": "Mostrar",
+      "all": "Todas",
+      "good": "Boa",
+      "faulty": "Com defeito"
+    },
+    "unknownVehicle": "Veículo não informado",
+    "unknownVehicleMarine": "Embarcação não informada",
+    "status": {
+      "pending": "Aguardando revisão",
+      "published": "Publicada",
+      "rejected": "Não publicada"
+    },
+    "hasRecording": "Arquivo do PicoScope para baixar",
+    "hasCsv": "Exportação CSV para baixar",
+    "kind": {
+      "good": "Boa",
+      "faulty": "Com defeito"
+    },
+    "condition": {
+      "key_on": "Ignição ligada, motor desligado",
+      "cranking": "Partida",
+      "idle": "Marcha lenta",
+      "rpm_2500": "2500 rpm",
+      "driving": "Rodando",
+      "drivingMarine": "Navegando",
+      "under_load": "Sob carga"
+    },
+    "chart": {
+      "label": "Forma de onda de {channels} em {span}",
+      "perDivision": "{value}/div",
+      "noSample": "sem amostra",
+      "between": "{lo} a {hi}"
+    },
+    "view": {
+      "label": "Mostrar",
+      "trace": "Forma de onda",
+      "picture": "Foto da tela"
+    },
+    "sharedOn": "Compartilhada em {date}",
+    "reason": "Motivo: {reason}",
+    "compare": {
+      "label": "Comparar com",
+      "none": "Nada",
+      "close": "Parar de comparar"
+    },
+    "note": "Observações",
+    "writtenIn": "Escrito em {language}",
+    "files": {
+      "title": "Arquivos",
+      "psdata": "Gravação do PicoScope",
+      "csv": "Exportação CSV",
+      "pssettings": "Configurações do osciloscópio",
+      "openHint": "Abra a gravação no PicoScope para ampliar, medir e ver todos os canais."
+    },
+    "nothingToDraw": "Só a gravação foi compartilhada. Baixe-a para abrir no PicoScope.",
+    "openPicture": "Abrir a foto em tamanho real",
+    "close": "Fechar",
+    "withdraw": "Retirar",
+    "withdrawTitle": "Retirar esta forma de onda?",
+    "withdrawConfirm": "Ela é apagada com seus arquivos, e nenhuma outra oficina a verá.",
+    "withdrawn": "Forma de onda retirada",
+    "withdrawFailed": "Não foi possível retirar a forma de onda agora. Tente de novo em instantes.",
+    "shareDialog": {
+      "title": "Compartilhar uma forma de onda para {code}",
+      "description": "Outras oficinas a veem quando uma pessoa da Torqvoice a tiver revisado.",
+      "meaningHint": "Este código significa outra coisa em outras centrais. Sua forma de onda é compartilhada só para este significado.",
+      "kind": "O que ela mostra?",
+      "kindHint": {
+        "good": "Como fica num carro sem defeito",
+        "goodMarine": "Como fica num motor sem defeito",
+        "faulty": "Como ficou com o defeito",
+        "faultyMarine": "Como ficou com o defeito"
+      },
+      "measured": "O que você mediu?",
+      "measuredPlaceholder": "Sensor de rotação e eixo de comando de admissão",
+      "measurements": {
+        "crank": "Sensor de rotação",
+        "camCrank": "Comando e virabrequim",
+        "injector": "Bico injetor",
+        "ignition": "Bobina de ignição",
+        "compression": "Compressão relativa",
+        "lambda": "Sonda lambda",
+        "maf": "Sensor de fluxo de ar",
+        "alternator": "Ondulação do alternador",
+        "canBus": "Rede CAN",
+        "fuelPump": "Corrente da bomba de combustível"
+      },
+      "condition": "Como o motor estava funcionando? (opcional)",
+      "files": "Arquivos",
+      "filesHint": "Basta um. O arquivo do PicoScope permite abrir no PicoScope; uma exportação CSV ou uma foto permite ver aqui mesmo.",
+      "phone": {
+        "button": "Foto do celular",
+        "title": "Tire uma foto da tela do osciloscópio",
+        "body": "Leia o código com a câmera do celular e tire uma foto da tela. Ela aparece aqui assim que chegar.",
+        "received": "A foto do seu celular chegou"
+      },
+      "drop": "Solte aqui o arquivo do PicoScope, ou clique para escolher",
+      "dropMore": "Adicionar outro arquivo",
+      "dropTypes": ".psdata, .pssettings, uma exportação CSV ou uma foto da tela",
+      "role": {
+        "psdata": "Gravação do PicoScope",
+        "csv": "Exportação CSV",
+        "image": "Foto da tela",
+        "pssettings": "Configurações do osciloscópio"
+      },
+      "csvNotScope": "Isto não é uma exportação de osciloscópio. Salve de novo no PicoScope como CSV.",
+      "remove": "Remover {name}",
+      "fromPhone": "Do seu celular",
+      "previewHint": "É assim que outras oficinas vão vê-la.",
+      "csvTip": "Dica: adicione também uma exportação CSV, para que outros vejam a forma de onda aqui sem abrir o PicoScope. No PicoScope, use Salvar como e escolha CSV.",
+      "note": "Observações (opcional)",
+      "notePlaceholder": "Onde você mediu, a escala usada e o que observar. Por exemplo: pino 2 por trás do conector do sensor, a falha dos dentes ausentes aparece uma vez por volta.",
+      "notePlaceholderMarine": "Onde você mediu, a escala usada e o que observar.",
+      "sending": "Enviando {percent}%",
+      "send": "Compartilhar forma de onda",
+      "sent": "Obrigado. Sua forma de onda está aguardando revisão.",
+      "missing": {
+        "kind": "Escolha boa ou com defeito",
+        "title": "Diga o que você mediu",
+        "file": "Adicione um arquivo ou uma foto",
+        "csv": "Remova o CSV que não é uma exportação de osciloscópio"
+      },
+      "notAScopeFile": "{name} não é um arquivo do PicoScope, uma exportação CSV nem uma foto",
+      "fileTooBig": "{name} é maior que {size}",
+      "errors": {
+        "failed": "Não foi possível enviar sua forma de onda agora. Tente de novo em instantes.",
+        "photo": "Não foi possível ler a foto. Tente outra.",
+        "csv": "O CSV não é uma exportação de osciloscópio. Salve de novo no PicoScope como CSV.",
+        "too_large": "Os arquivos são grandes demais.",
+        "no_file": "Adicione a gravação, uma exportação CSV ou uma foto.",
+        "paused": "O compartilhamento de formas de onda está pausado agora. Tente mais tarde.",
+        "demo": "O compartilhamento de formas de onda está desligado na demonstração."
+      }
+    }
+  },
+  "improve": {
+    "button": "Ajude-nos a melhorar",
+    "title": "Ajude-nos a melhorar os códigos de falha",
+    "description": "Sabe onde uma marca publica seus próprios códigos, viu um erro ou tem uma ideia? Conte para a gente. Vai direto para a equipe da Torqvoice.",
+    "topic": "Sobre o que é? (opcional)",
+    "topics": {
+      "makes": "Códigos que faltam",
+      "wrong": "Algo está errado",
+      "source": "Uma boa fonte ou link",
+      "idea": "Uma ideia"
+    },
+    "message": "Sua mensagem",
+    "placeholders": {
+      "makes": "Onde encontrá-los, por exemplo um link para a lista de códigos da marca, e quais modelos ela cobre.",
+      "wrong": "Qual código, o que diz e o que deveria dizer. Um link para onde você encontrou ajuda.",
+      "source": "Cole o link e diga o que ele contém.",
+      "idea": "O que tornaria os códigos de falha mais úteis na sua oficina? Links são bem-vindos."
+    },
+    "links": "Links na sua mensagem",
+    "about": "Sobre {code}",
+    "leaveOut": "Tirar",
+    "replyHint": "Respondemos no e-mail da sua conta.",
+    "cancel": "Cancelar",
+    "send": "Enviar",
+    "sent": "Obrigado. Sua mensagem está a caminho da equipe da Torqvoice.",
+    "errors": {
+      "failed": "Não foi possível enviar sua mensagem agora. Tente de novo em instantes.",
+      "limited": "Você já enviou algumas mensagens. Tente de novo daqui a uma hora."
+    }
+  },
+  "import": {
+    "button": "Importar",
+    "title": "Importar códigos de falha",
+    "description": "Adicione os códigos próprios de uma marca, ou mais sobre códigos que temos. Uma pessoa da Torqvoice verifica cada código antes que qualquer oficina o veja.",
+    "yours": "Suas importações",
+    "codes": "{count, plural, one {# código} other {# códigos}}",
+    "status": {
+      "published": "{count} publicados",
+      "pending": "{count} aguardando",
+      "rejected": "{count} não publicados"
+    },
+    "examples": {
+      "title": "Comece com um exemplo",
+      "sheet": "Planilha de exemplo",
+      "sheetHint": "Excel, uma linha por código",
+      "json": "JSON de exemplo",
+      "jsonHint": "Cada campo com nome",
+      "columns": "Mantenha os nomes das colunas: Code, Title, Description, Common issues, How to fix, Good to know e Causes. Só Code e Title são necessários. Ponha cada causa em uma linha, com (high), (medium) ou (low) depois, se souber.",
+      "csv": "Baixar como CSV"
+    },
+    "make": "Marca",
+    "pickMake": "Escolha uma marca",
+    "language": "Idioma do texto",
+    "source": "De onde vem (opcional)",
+    "sourcePlaceholder": "Link ou livro",
+    "checking": "Lendo e conferindo seus códigos",
+    "removeFile": "Remover o arquivo",
+    "drop": "Solte seu arquivo aqui, ou clique para escolher",
+    "dropTypes": ".xlsx, .csv ou .json, até 2.000 códigos",
+    "fileProblems": {
+      "unreadable": "Não foi possível ler o arquivo. Salve-o de novo como .xlsx, .csv ou .json.",
+      "empty": "O arquivo não tem códigos.",
+      "no_code_column": "Nenhuma coluna Code encontrada. Comece pela planilha de exemplo.",
+      "too_many": "O arquivo é grande demais: no máximo 2.000 códigos e 5 MB. Divida-o em partes.",
+      "format": "Este tipo de arquivo não é compatível. Use .xlsx, .csv ou .json.",
+      "legacy_xls": "Arquivos .xls antigos não são compatíveis. Salve-o como .xlsx no Excel."
+    },
+    "counts": {
+      "new": "{count} novos",
+      "change": "{count} complementam os nossos",
+      "problem": "{count} precisam de correção",
+      "same": "{count} já iguais"
+    },
+    "rows": "Códigos no arquivo",
+    "row": "Linha {n}",
+    "more": "e mais {count}",
+    "problemsLeftOut": "Códigos que precisam de correção ficam de fora. Corrija-os no arquivo e solte-o de novo, ou envie o resto agora.",
+    "rights": "Posso compartilhar isto. Não foi copiado de uma fonte paga ou protegida.",
+    "missing": {
+      "make": "Escolha a marca primeiro",
+      "file": "Adicione seu arquivo",
+      "nothing": "Nada no arquivo complementa o que temos",
+      "rights": "Marque a caixa para enviar"
+    },
+    "reviewHint": "Nada é publicado antes de uma pessoa verificar.",
+    "cancel": "Cancelar",
+    "send": "{count, plural, one {Enviar # código para revisão} other {Enviar # códigos para revisão}}",
+    "sendNone": "Enviar para revisão",
+    "sent": "{count, plural, one {Enviado. # código aguarda revisão.} other {Enviado. # códigos aguardam revisão.}}",
+    "errors": {
+      "failed": "Não foi possível conferir ou enviar seu arquivo agora. Tente de novo em instantes.",
+      "paused": "O envio de códigos está pausado agora. Tente mais tarde.",
+      "nothing": "Nada no arquivo complementa o que temos.",
+      "demo": "A importação de códigos está desligada na demonstração.",
+      "rights": "Marque a caixa dizendo que você pode compartilhar isto.",
+      "too_large": "O arquivo é grande demais: no máximo 2.000 códigos e 5 MB."
+    },
+    "problems": {
+      "code": "Não é um código que a {make} usa",
+      "duplicate": "O mesmo código duas vezes no arquivo",
+      "title": "Precisa de um título",
+      "empty": "Nada escrito",
+      "too_long": "Longo demais: título até 200 caracteres, cada texto até 4.000",
+      "likelihood": "A probabilidade deve ser low, medium ou high",
+      "meanings": "A {make} usa este código para mais de uma coisa. Compartilhe na página do código."
+    },
+    "state": {
+      "new": "Novo",
+      "change": "Acrescenta {what}"
+    },
+    "fields": {
+      "title": "título",
+      "description": "significado",
+      "common_issues": "problemas comuns",
+      "resolution": "como corrigir",
+      "advisory": "bom saber",
+      "causes": "causas"
+    },
+    "addsCauses": "{count, plural, one {# causa} other {# causas}}",
+    "searchMake": "Buscar marcas",
+    "noMake": "Nenhuma marca encontrada",
+    "useMake": "Usar {name}",
+    "groups": {
+      "cars": "Carros e utilitários",
+      "marine": "Barcos e motores"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marcas com significado próprio para {code}",
+    "thisCar": "Marca deste veículo"
   }
   }
 }
 }

+ 351 - 3
messages/pt-PT/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Dados dos fabricantes",
     "manufacturer": "Dados dos fabricantes",
     "related": "Juntamente com outros códigos",
     "related": "Juntamente com outros códigos",
     "diagnosis": "Dica de diagnóstico",
     "diagnosis": "Dica de diagnóstico",
+    "common_issues": "Problemas comuns",
+    "resolution": "Solução",
+    "advisory": "Recomendação",
     "other": "Bom saber"
     "other": "Bom saber"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Modelo",
     "model": "Modelo",
     "year": "Ano",
     "year": "Ano",
     "vehicleHint": "Só são partilhados a marca, o modelo, o ano e o motor, nunca a matrícula, o VIN, o cliente ou o nome da sua oficina.",
     "vehicleHint": "Só são partilhados a marca, o modelo, o ano e o motor, nunca a matrícula, o VIN, o cliente ou o nome da sua oficina.",
+    "meaning": "Significado",
+    "meaningHint": "Este código significa outra coisa noutras unidades de comando. A sua solução é partilhada só para este significado.",
     "vehicleHintMarine": "Só são partilhados a marca, o modelo, o ano e o motor, nunca o registo, o número do casco, o cliente ou o nome da sua oficina.",
     "vehicleHintMarine": "Só são partilhados a marca, o modelo, o ano e o motor, nunca o registo, o número do casco, o cliente ou o nome da sua oficina.",
     "send": "Partilhar solução",
     "send": "Partilhar solução",
     "sent": "Obrigado. A sua solução aguarda revisão.",
     "sent": "Obrigado. A sua solução aguarda revisão.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Adicionar códigos} one {Adicionar # código} other {Adicionar # códigos}}",
     "add": "{count, plural, =0 {Adicionar códigos} one {Adicionar # código} other {Adicionar # códigos}}",
     "added": "{count, plural, one {# código de avaria adicionado} other {# códigos de avaria adicionados}}",
     "added": "{count, plural, one {# código de avaria adicionado} other {# códigos de avaria adicionados}}",
     "skipped": "{count, plural, one {# código já estava neste trabalho} other {# códigos já estavam neste trabalho}}",
     "skipped": "{count, plural, one {# código já estava neste trabalho} other {# códigos já estavam neste trabalho}}",
-    "failed": "Não foi possível adicionar os códigos"
+    "failed": "Não foi possível adicionar os códigos",
+    "descriptionOem": "Escreva ou cole os códigos do equipamento de diagnóstico, códigos OBD como P0420 ou códigos BMW como 2A82. Cada um torna-se uma observação neste trabalho, com o significado guardado.",
+    "placeholderOem": "P0420, 2A82 memorizado"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Visto {date}",
     "seenBefore": "Visto {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Os códigos de avaria são novos",
     "title": "Os códigos de avaria são novos",
-    "body": "Estamos sempre a adicionar códigos, por isso ainda não estão todos aqui. Por agora, estão incluídos os códigos OBD-II normalizados; os códigos específicos de cada fabricante ainda não."
+    "body": "Estão incluídos os códigos OBD-II normalizados. Estamos a trabalhar para acrescentar os códigos próprios das marcas, e estão sempre a chegar códigos novos. Sabe onde encontrar os códigos de uma marca? Diga-nos em “Ajude-nos a melhorar”.",
+    "bodyBmw": "Estão incluídos os códigos OBD-II normalizados e os códigos próprios da BMW, partilhados pela MINI e pela Rolls-Royce. Estamos a trabalhar para acrescentar mais marcas, e estão sempre a chegar códigos novos. Sabe onde encontrar os códigos de uma marca? Diga-nos em “Ajude-nos a melhorar”.",
+    "bodyMakes": "Estão incluídos os códigos OBD-II normalizados e os códigos próprios de {makes}. Estamos a trabalhar para acrescentar mais marcas, e estão sempre a chegar códigos novos. Sabe onde encontrar os códigos de uma marca? Diga-nos em “Ajude-nos a melhorar”."
   },
   },
   "support": {
   "support": {
     "button": "Contactar o suporte",
     "button": "Contactar o suporte",
@@ -245,6 +254,345 @@
     "done": "Quando terminar, volte ao computador e partilhe a solução.",
     "done": "Quando terminar, volte ao computador e partilhe a solução.",
     "privacy": "Deixe rostos, matrículas e dados de clientes fora da imagem.",
     "privacy": "Deixe rostos, matrículas e dados de clientes fora da imagem.",
     "expired": "Este código expirou. Mostre um novo no computador.",
     "expired": "Este código expirou. Mostre um novo no computador.",
-    "invalid": "Este código não funciona. Mostre um novo no computador."
+    "invalid": "Este código não funciona. Mostre um novo no computador.",
+    "waveform": {
+      "title": "Fotografia do ecrã do osciloscópio",
+      "forCode": "para a forma de onda que está a partilhar no computador",
+      "intro": "Tire ou escolha uma fotografia do ecrã do osciloscópio. Aparece no computador.",
+      "full": "Uma forma de onda tem uma fotografia. Remova-a no computador para tirar outra.",
+      "done": "Volte ao computador e partilhe a forma de onda.",
+      "added": "Fotografia adicionada"
+    }
+  },
+  "oem": {
+    "badge": "Código {make}",
+    "summary": "{count, plural, one {# significado} other {# significados}}. Um código BMW pode significar algo diferente em cada unidade de comando.",
+    "matches": "Corresponde a este carro",
+    "noMatch": "Nenhum destes significados está indicado para o motor deste carro. Verifique de que unidade de comando o equipamento leu o código.",
+    "inEnglish": "Texto inglês da {make}: {text}",
+    "bmwText": "Texto da {make}: {text}",
+    "units": "Unidades de comando",
+    "moreUnits": "+{count} mais",
+    "otherChassis": "Outros chassis",
+    "years": "{from} a {to}",
+    "yearsFrom": "desde {from}",
+    "source": "Os significados vêm dos textos de diagnóstico da própria BMW, a maioria em alemão.",
+    "notServed": "Os códigos {make} ainda não estão disponíveis.",
+    "notFound": "{code} não está nos dados da {make}.",
+    "whereUsed": "Onde a {make} o usa",
+    "models": "Modelos",
+    "engineList": "Motores",
+    "fewerUnits": "Mostrar menos",
+    "fitsThisCar": "Este veículo",
+    "meanings": "Significados",
+    "otherMeanings": "e {count, plural, one {mais # significado} other {mais # significados}}"
+  },
+  "research": {
+    "inviteTitle": "Pesquisar uma solução online",
+    "invite": "Pesquisamos {code} em documentação do fabricante, boletins técnicos e fóruns de oficinas. Só uma resposta específica com fontes é guardada, e uma pessoa da Torqvoice verifica-a.",
+    "button": "Pesquisar uma solução",
+    "working": "A pesquisar {code}",
+    "steps": {
+      "bulletins": "A pesquisar documentação do fabricante e boletins técnicos",
+      "forums": "A ler fóruns de oficinas com soluções confirmadas",
+      "agree": "A verificar se as fontes concordam",
+      "writing": "A escrever os passos"
+    },
+    "workingHint": "Demora um ou dois minutos. O que for encontrado fica no código para todas as oficinas.",
+    "runningTitle": "A ser pesquisado agora",
+    "runningHint": "Outra oficina pediu há pouco. Volte a verificar dentro de um minuto.",
+    "checkAgain": "Verificar novamente",
+    "title": "Solução pesquisada",
+    "pending": "A aguardar verificação de uma pessoa",
+    "published": "Verificado pela Torqvoice",
+    "pendingHint": "Encontrado por IA a partir das fontes abaixo. Confirme no veículo antes de substituir peças.",
+    "sources": "Fontes",
+    "helpfulQuestion": "Isto resolveu?",
+    "helpfulYes": "Sim, resolveu",
+    "helpfulNo": "Não resolveu",
+    "thanks": "Obrigado, registado",
+    "missedTitle": "Nenhuma solução fiável encontrada online",
+    "missed": "Não surgiu nada específico sobre {code}, por isso nada foi guardado. Registámos que as oficinas precisam deste código.",
+    "searchedOn": "Pesquisado em {date}",
+    "shareInstead": "Partilhe a sua solução",
+    "errors": {
+      "busy": "Este código está a ser pesquisado agora. Volte a verificar dentro de um minuto.",
+      "limited": "A sua oficina já usou as pesquisas de hoje. Tente amanhã.",
+      "failed": "A pesquisa não pôde ser feita agora. Tente mais tarde."
+    }
+  },
+  "waveforms": {
+    "title": "Formas de onda",
+    "description": "Gravações de osciloscópio de outras oficinas, boas e com avaria, para comparar com o que mede.",
+    "share": "Partilhar uma forma de onda",
+    "unavailable": "As formas de onda não estão disponíveis neste momento.",
+    "empty": "Ainda não há formas de onda para {code}",
+    "emptyHint": "Tem uma gravação boa? Partilhe-a, e o próximo técnico às voltas com este código terá com que comparar.",
+    "filter": {
+      "label": "Mostrar",
+      "all": "Todas",
+      "good": "Boa",
+      "faulty": "Com avaria"
+    },
+    "unknownVehicle": "Veículo não indicado",
+    "unknownVehicleMarine": "Embarcação não indicada",
+    "status": {
+      "pending": "A aguardar revisão",
+      "published": "Publicada",
+      "rejected": "Não publicada"
+    },
+    "hasRecording": "Ficheiro do PicoScope para transferir",
+    "hasCsv": "Exportação CSV para transferir",
+    "kind": {
+      "good": "Boa",
+      "faulty": "Com avaria"
+    },
+    "condition": {
+      "key_on": "Ignição ligada, motor parado",
+      "cranking": "Arranque",
+      "idle": "Ralenti",
+      "rpm_2500": "2500 rpm",
+      "driving": "Em andamento",
+      "drivingMarine": "A navegar",
+      "under_load": "Em carga"
+    },
+    "chart": {
+      "label": "Forma de onda de {channels} em {span}",
+      "perDivision": "{value}/div",
+      "noSample": "sem amostra",
+      "between": "{lo} a {hi}"
+    },
+    "view": {
+      "label": "Mostrar",
+      "trace": "Forma de onda",
+      "picture": "Fotografia do ecrã"
+    },
+    "sharedOn": "Partilhada a {date}",
+    "reason": "Motivo: {reason}",
+    "compare": {
+      "label": "Comparar com",
+      "none": "Nada",
+      "close": "Deixar de comparar"
+    },
+    "note": "Notas",
+    "writtenIn": "Escrito em {language}",
+    "files": {
+      "title": "Ficheiros",
+      "psdata": "Gravação do PicoScope",
+      "csv": "Exportação CSV",
+      "pssettings": "Definições do osciloscópio",
+      "openHint": "Abra a gravação no PicoScope para ampliar, medir e ver todos os canais."
+    },
+    "nothingToDraw": "Só a gravação foi partilhada. Transfira-a para a abrir no PicoScope.",
+    "openPicture": "Abrir a fotografia em tamanho real",
+    "close": "Fechar",
+    "withdraw": "Retirar",
+    "withdrawTitle": "Retirar esta forma de onda?",
+    "withdrawConfirm": "É apagada com os seus ficheiros, e nenhuma outra oficina a verá.",
+    "withdrawn": "Forma de onda retirada",
+    "withdrawFailed": "Não foi possível retirar a forma de onda neste momento. Tente novamente daqui a pouco.",
+    "shareDialog": {
+      "title": "Partilhar uma forma de onda para {code}",
+      "description": "Outras oficinas veem-na quando uma pessoa da Torqvoice a tiver verificado.",
+      "meaningHint": "Este código significa outra coisa noutras centralinas. A sua forma de onda é partilhada só para este significado.",
+      "kind": "O que mostra?",
+      "kindHint": {
+        "good": "Como fica num carro sem avaria",
+        "goodMarine": "Como fica num motor sem avaria",
+        "faulty": "Como ficou com a avaria",
+        "faultyMarine": "Como ficou com a avaria"
+      },
+      "measured": "O que mediu?",
+      "measuredPlaceholder": "Sensor da cambota e árvore de cames de admissão",
+      "measurements": {
+        "crank": "Sensor da cambota",
+        "camCrank": "Árvore de cames e cambota",
+        "injector": "Injetor",
+        "ignition": "Bobina de ignição",
+        "compression": "Compressão relativa",
+        "lambda": "Sonda lambda",
+        "maf": "Medidor de massa de ar",
+        "alternator": "Ondulação do alternador",
+        "canBus": "Barramento CAN",
+        "fuelPump": "Corrente da bomba de combustível"
+      },
+      "condition": "Como estava a trabalhar o motor? (opcional)",
+      "files": "Ficheiros",
+      "filesHint": "Basta um. O ficheiro do PicoScope permite abri-lo no PicoScope; uma exportação CSV ou uma fotografia permite vê-lo aqui mesmo.",
+      "phone": {
+        "button": "Fotografia do telemóvel",
+        "title": "Tire uma fotografia ao ecrã do osciloscópio",
+        "body": "Leia o código com a câmara do telemóvel e tire uma fotografia ao ecrã. Aparece aqui assim que chegar.",
+        "received": "A fotografia do seu telemóvel chegou"
+      },
+      "drop": "Largue aqui o ficheiro do PicoScope, ou clique para escolher",
+      "dropMore": "Adicionar outro ficheiro",
+      "dropTypes": ".psdata, .pssettings, uma exportação CSV ou uma fotografia do ecrã",
+      "role": {
+        "psdata": "Gravação do PicoScope",
+        "csv": "Exportação CSV",
+        "image": "Fotografia do ecrã",
+        "pssettings": "Definições do osciloscópio"
+      },
+      "csvNotScope": "Isto não é uma exportação de osciloscópio. Guarde-a novamente no PicoScope como CSV.",
+      "remove": "Remover {name}",
+      "fromPhone": "Do seu telemóvel",
+      "previewHint": "É assim que as outras oficinas a vão ver.",
+      "csvTip": "Dica: adicione também uma exportação CSV, para que outros vejam a forma de onda aqui sem abrir o PicoScope. No PicoScope, use Guardar como e escolha CSV.",
+      "note": "Notas (opcional)",
+      "notePlaceholder": "Onde mediu, a escala que usou e o que observar. Por exemplo: pino 2 por trás da ficha do sensor, a falha dos dentes em falta aparece uma vez por volta.",
+      "notePlaceholderMarine": "Onde mediu, a escala que usou e o que observar.",
+      "sending": "A enviar {percent}%",
+      "send": "Partilhar forma de onda",
+      "sent": "Obrigado. A sua forma de onda aguarda revisão.",
+      "missing": {
+        "kind": "Escolha boa ou com avaria",
+        "title": "Indique o que mediu",
+        "file": "Adicione um ficheiro ou uma fotografia",
+        "csv": "Remova o CSV que não é uma exportação de osciloscópio"
+      },
+      "notAScopeFile": "{name} não é um ficheiro do PicoScope, uma exportação CSV nem uma fotografia",
+      "fileTooBig": "{name} tem mais de {size}",
+      "errors": {
+        "failed": "Não foi possível enviar a sua forma de onda neste momento. Tente novamente daqui a pouco.",
+        "photo": "Não foi possível ler a fotografia. Experimente outra.",
+        "csv": "O CSV não é uma exportação de osciloscópio. Guarde-o novamente no PicoScope como CSV.",
+        "too_large": "Os ficheiros são demasiado grandes.",
+        "no_file": "Adicione a gravação, uma exportação CSV ou uma fotografia.",
+        "paused": "A partilha de formas de onda está em pausa neste momento. Tente mais tarde.",
+        "demo": "A partilha de formas de onda está desligada na demonstração."
+      }
+    }
+  },
+  "improve": {
+    "button": "Ajude-nos a melhorar",
+    "title": "Ajude-nos a melhorar os códigos de avaria",
+    "description": "Sabe onde uma marca publica os seus próprios códigos, viu um erro ou tem uma ideia? Diga-nos. Vai diretamente para a equipa da Torqvoice.",
+    "topic": "De que se trata? (opcional)",
+    "topics": {
+      "makes": "Códigos em falta",
+      "wrong": "Algo está errado",
+      "source": "Uma boa fonte ou ligação",
+      "idea": "Uma ideia"
+    },
+    "message": "A sua mensagem",
+    "placeholders": {
+      "makes": "Onde os encontrar, por exemplo uma ligação para a lista de códigos da marca, e que modelos abrange.",
+      "wrong": "Que código, o que diz e o que devia dizer. Uma ligação para onde o encontrou ajuda.",
+      "source": "Cole a ligação e diga o que contém.",
+      "idea": "O que tornaria os códigos de avaria mais úteis na sua oficina? Ligações são bem-vindas."
+    },
+    "links": "Ligações na sua mensagem",
+    "about": "Sobre {code}",
+    "leaveOut": "Retirar",
+    "replyHint": "Respondemos para o e-mail da sua conta.",
+    "cancel": "Cancelar",
+    "send": "Enviar",
+    "sent": "Obrigado. A sua mensagem está a caminho da equipa da Torqvoice.",
+    "errors": {
+      "failed": "Não foi possível enviar a sua mensagem neste momento. Tente novamente daqui a pouco.",
+      "limited": "Já enviou algumas mensagens. Tente novamente daqui a uma hora."
+    }
+  },
+  "import": {
+    "button": "Importar",
+    "title": "Importar códigos de avaria",
+    "description": "Adicione os códigos próprios de uma marca, ou mais sobre códigos que temos. Uma pessoa da Torqvoice verifica cada código antes que alguma oficina o veja.",
+    "yours": "As suas importações",
+    "codes": "{count, plural, one {# código} other {# códigos}}",
+    "status": {
+      "published": "{count} publicados",
+      "pending": "{count} a aguardar",
+      "rejected": "{count} não publicados"
+    },
+    "examples": {
+      "title": "Comece com um exemplo",
+      "sheet": "Folha de cálculo de exemplo",
+      "sheetHint": "Excel, uma linha por código",
+      "json": "JSON de exemplo",
+      "jsonHint": "Cada campo com nome",
+      "columns": "Mantenha os nomes das colunas: Code, Title, Description, Common issues, How to fix, Good to know e Causes. Só Code e Title são necessários. Ponha cada causa numa linha, com (high), (medium) ou (low) a seguir, se souber.",
+      "csv": "Transferir como CSV"
+    },
+    "make": "Marca",
+    "pickMake": "Escolha uma marca",
+    "language": "Idioma do texto",
+    "source": "De onde vem (opcional)",
+    "sourcePlaceholder": "Ligação ou livro",
+    "checking": "A ler e verificar os seus códigos",
+    "removeFile": "Remover o ficheiro",
+    "drop": "Largue aqui o ficheiro, ou clique para escolher",
+    "dropTypes": ".xlsx, .csv ou .json, até 2000 códigos",
+    "fileProblems": {
+      "unreadable": "Não foi possível ler o ficheiro. Guarde-o novamente como .xlsx, .csv ou .json.",
+      "empty": "O ficheiro não tem códigos.",
+      "no_code_column": "Não foi encontrada uma coluna Code. Comece pela folha de cálculo de exemplo.",
+      "too_many": "O ficheiro é demasiado grande: no máximo 2000 códigos e 5 MB. Divida-o em partes.",
+      "format": "Este tipo de ficheiro não é suportado. Use .xlsx, .csv ou .json.",
+      "legacy_xls": "Os ficheiros .xls antigos não são suportados. Guarde-o como .xlsx no Excel."
+    },
+    "counts": {
+      "new": "{count} novos",
+      "change": "{count} completam os nossos",
+      "problem": "{count} precisam de correção",
+      "same": "{count} já iguais"
+    },
+    "rows": "Códigos no ficheiro",
+    "row": "Linha {n}",
+    "more": "e mais {count}",
+    "problemsLeftOut": "Os códigos que precisam de correção ficam de fora. Corrija-os no ficheiro e largue-o de novo, ou envie já o resto.",
+    "rights": "Posso partilhar isto. Não foi copiado de uma fonte paga ou protegida.",
+    "missing": {
+      "make": "Escolha primeiro a marca",
+      "file": "Adicione o seu ficheiro",
+      "nothing": "Nada no ficheiro completa o que temos",
+      "rights": "Marque a caixa para enviar"
+    },
+    "reviewHint": "Nada é publicado antes de uma pessoa o verificar.",
+    "cancel": "Cancelar",
+    "send": "{count, plural, one {Enviar # código para revisão} other {Enviar # códigos para revisão}}",
+    "sendNone": "Enviar para revisão",
+    "sent": "{count, plural, one {Enviado. # código aguarda revisão.} other {Enviado. # códigos aguardam revisão.}}",
+    "errors": {
+      "failed": "Não foi possível verificar ou enviar o ficheiro neste momento. Tente novamente daqui a pouco.",
+      "paused": "O envio de códigos está em pausa neste momento. Tente mais tarde.",
+      "nothing": "Nada no ficheiro completa o que temos.",
+      "demo": "A importação de códigos está desligada na demonstração.",
+      "rights": "Marque a caixa a dizer que pode partilhar isto.",
+      "too_large": "O ficheiro é demasiado grande: no máximo 2000 códigos e 5 MB."
+    },
+    "problems": {
+      "code": "Não é um código que a {make} use",
+      "duplicate": "O mesmo código duas vezes no ficheiro",
+      "title": "Precisa de um título",
+      "empty": "Nada escrito",
+      "too_long": "Demasiado longo: título até 200 caracteres, cada texto até 4000",
+      "likelihood": "A probabilidade tem de ser low, medium ou high",
+      "meanings": "A {make} usa este código para mais do que uma coisa. Partilhe-o antes na página do código."
+    },
+    "state": {
+      "new": "Novo",
+      "change": "Acrescenta {what}"
+    },
+    "fields": {
+      "title": "título",
+      "description": "significado",
+      "common_issues": "problemas frequentes",
+      "resolution": "como reparar",
+      "advisory": "bom saber",
+      "causes": "causas"
+    },
+    "addsCauses": "{count, plural, one {# causa} other {# causas}}",
+    "searchMake": "Procurar marcas",
+    "noMake": "Nenhuma marca encontrada",
+    "useMake": "Usar {name}",
+    "groups": {
+      "cars": "Automóveis e comerciais",
+      "marine": "Barcos e motores"
+    }
+  },
+  "makeCodes": {
+    "heading": "Marcas com significado próprio para {code}",
+    "thisCar": "Marca deste veículo"
   }
   }
 }
 }

+ 351 - 3
messages/ru/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Данные производителей",
     "manufacturer": "Данные производителей",
     "related": "Вместе с другими кодами",
     "related": "Вместе с другими кодами",
     "diagnosis": "Совет по диагностике",
     "diagnosis": "Совет по диагностике",
+    "common_issues": "Частые проблемы",
+    "resolution": "Устранение",
+    "advisory": "Рекомендация",
     "other": "Полезно знать"
     "other": "Полезно знать"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Модель",
     "model": "Модель",
     "year": "Год",
     "year": "Год",
     "vehicleHint": "Передаются только марка, модель, год и двигатель, никогда номерной знак, VIN, клиент или название вашей мастерской.",
     "vehicleHint": "Передаются только марка, модель, год и двигатель, никогда номерной знак, VIN, клиент или название вашей мастерской.",
+    "meaning": "Значение",
+    "meaningHint": "В других блоках управления этот код означает другое. Ваше решение публикуется только для этого значения.",
     "vehicleHintMarine": "Передаются только марка, модель, год и двигатель, никогда регистрационный номер, номер корпуса, клиент или название вашей мастерской.",
     "vehicleHintMarine": "Передаются только марка, модель, год и двигатель, никогда регистрационный номер, номер корпуса, клиент или название вашей мастерской.",
     "send": "Поделиться решением",
     "send": "Поделиться решением",
     "sent": "Спасибо. Ваше решение ждёт проверки.",
     "sent": "Спасибо. Ваше решение ждёт проверки.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Добавить коды} one {Добавить # код} few {Добавить # кода} many {Добавить # кодов} other {Добавить # кода}}",
     "add": "{count, plural, =0 {Добавить коды} one {Добавить # код} few {Добавить # кода} many {Добавить # кодов} other {Добавить # кода}}",
     "added": "{count, plural, one {Добавлен # код неисправности} few {Добавлено # кода неисправности} many {Добавлено # кодов неисправностей} other {Добавлено # кода неисправности}}",
     "added": "{count, plural, one {Добавлен # код неисправности} few {Добавлено # кода неисправности} many {Добавлено # кодов неисправностей} other {Добавлено # кода неисправности}}",
     "skipped": "{count, plural, one {# код уже был в этой работе} few {# кода уже были в этой работе} many {# кодов уже были в этой работе} other {# кода уже были в этой работе}}",
     "skipped": "{count, plural, one {# код уже был в этой работе} few {# кода уже были в этой работе} many {# кодов уже были в этой работе} other {# кода уже были в этой работе}}",
-    "failed": "Не удалось добавить коды"
+    "failed": "Не удалось добавить коды",
+    "descriptionOem": "Введите или вставьте коды из диагностического сканера: коды OBD, например P0420, или коды BMW, например 2A82. Каждый станет наблюдением в этом заказе с сохранённым значением.",
+    "placeholderOem": "P0420, 2A82 сохранён"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Был {date}",
     "seenBefore": "Был {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Коды неисправностей появились недавно",
     "title": "Коды неисправностей появились недавно",
-    "body": "Мы постоянно добавляем коды, поэтому здесь пока есть не все. Сейчас доступны стандартные коды OBD-II; коды конкретных производителей пока не включены."
+    "body": "Здесь есть стандартные коды OBD-II. Мы работаем над добавлением собственных кодов производителей, и новые коды появляются постоянно. Знаете, где найти коды какой-то марки? Расскажите нам через «Помогите нам стать лучше».",
+    "bodyBmw": "Здесь есть стандартные коды OBD-II и собственные коды BMW, общие с MINI и Rolls-Royce. Мы работаем над добавлением других марок, и новые коды появляются постоянно. Знаете, где найти коды какой-то марки? Расскажите нам через «Помогите нам стать лучше».",
+    "bodyMakes": "Здесь есть стандартные коды OBD-II и собственные коды марок: {makes}. Мы работаем над добавлением других марок, и новые коды появляются постоянно. Знаете, где найти коды какой-то марки? Расскажите нам через «Помогите нам стать лучше»."
   },
   },
   "support": {
   "support": {
     "button": "Связаться с поддержкой",
     "button": "Связаться с поддержкой",
@@ -245,6 +254,345 @@
     "done": "Когда закончите, вернитесь к компьютеру и поделитесь решением.",
     "done": "Когда закончите, вернитесь к компьютеру и поделитесь решением.",
     "privacy": "Не снимайте лица, номерные знаки и данные клиентов.",
     "privacy": "Не снимайте лица, номерные знаки и данные клиентов.",
     "expired": "Срок действия кода истёк. Покажите новый на компьютере.",
     "expired": "Срок действия кода истёк. Покажите новый на компьютере.",
-    "invalid": "Этот код не работает. Покажите новый на компьютере."
+    "invalid": "Этот код не работает. Покажите новый на компьютере.",
+    "waveform": {
+      "title": "Снимок экрана осциллографа",
+      "forCode": "для осциллограммы, которой вы делитесь на компьютере",
+      "intro": "Сделайте или выберите один снимок экрана осциллографа. Он появится на компьютере.",
+      "full": "У осциллограммы один снимок. Удалите его на компьютере, чтобы сделать новый.",
+      "done": "Вернитесь к компьютеру и поделитесь осциллограммой.",
+      "added": "Снимок добавлен"
+    }
+  },
+  "oem": {
+    "badge": "Код {make}",
+    "summary": "{count, plural, one {# значение} few {# значения} many {# значений} other {# значения}}. Код BMW может означать разное в каждом блоке управления.",
+    "matches": "Подходит этому автомобилю",
+    "noMatch": "Ни одно из этих значений не указано для двигателя этого автомобиля. Проверьте, из какого блока управления сканер считал код.",
+    "inEnglish": "Английский текст {make}: {text}",
+    "bmwText": "Текст {make}: {text}",
+    "units": "Блоки управления",
+    "moreUnits": "ещё {count}",
+    "otherChassis": "Другие кузова",
+    "years": "{from}–{to}",
+    "yearsFrom": "с {from}",
+    "source": "Значения взяты из диагностических текстов BMW, большинство на немецком.",
+    "notServed": "Коды {make} пока недоступны.",
+    "notFound": "{code} нет в данных {make}.",
+    "whereUsed": "Где {make} его использует",
+    "models": "Модели",
+    "engineList": "Двигатели",
+    "fewerUnits": "Показать меньше",
+    "fitsThisCar": "Этот автомобиль",
+    "meanings": "Значения",
+    "otherMeanings": "и ещё {count, plural, one {# значение} few {# значения} many {# значений} other {# значения}}"
+  },
+  "research": {
+    "inviteTitle": "Найти решение в интернете",
+    "invite": "Мы ищем {code} в документации производителя, сервисных бюллетенях и форумах мастерских. Сохраняется только конкретный ответ с источниками, и сотрудник Torqvoice его проверяет.",
+    "button": "Найти решение",
+    "working": "Поиск по {code}",
+    "steps": {
+      "bulletins": "Поиск в документации производителя и сервисных бюллетенях",
+      "forums": "Чтение форумов мастерских с подтверждёнными решениями",
+      "agree": "Проверка, что источники согласуются",
+      "writing": "Составление шагов"
+    },
+    "workingHint": "Это займёт минуту-две. Найденное останется у кода для всех мастерских.",
+    "runningTitle": "Сейчас идёт поиск",
+    "runningHint": "Другая мастерская только что запросила. Проверьте через минуту.",
+    "checkAgain": "Проверить снова",
+    "title": "Найденное решение",
+    "pending": "Ждёт проверки человеком",
+    "published": "Проверено Torqvoice",
+    "pendingHint": "Найдено ИИ по источникам ниже. Проверьте на автомобиле, прежде чем менять детали.",
+    "sources": "Источники",
+    "helpfulQuestion": "Это помогло?",
+    "helpfulYes": "Да, помогло",
+    "helpfulNo": "Нет, не помогло",
+    "thanks": "Спасибо, учтено",
+    "missedTitle": "Надёжного решения в интернете не найдено",
+    "missed": "Ничего конкретного по {code} не нашлось, поэтому ничего не сохранено. Мы отметили, что мастерским нужен этот код.",
+    "searchedOn": "Поиск {date}",
+    "shareInstead": "Поделиться своим решением",
+    "errors": {
+      "busy": "По этому коду сейчас идёт поиск. Проверьте через минуту.",
+      "limited": "Ваша мастерская использовала сегодняшние поиски. Попробуйте завтра.",
+      "failed": "Поиск сейчас не удался. Попробуйте позже."
+    }
+  },
+  "waveforms": {
+    "title": "Осциллограммы",
+    "description": "Записи с осциллографа от других сервисов, эталонные и с неисправностью, чтобы сравнить со своим замером.",
+    "share": "Поделиться осциллограммой",
+    "unavailable": "Осциллограммы сейчас недоступны.",
+    "empty": "Для {code} пока нет осциллограмм",
+    "emptyHint": "Есть эталонная запись? Поделитесь ею, и у следующего мастера с этим кодом будет с чем сравнить.",
+    "filter": {
+      "label": "Показать",
+      "all": "Все",
+      "good": "Эталон",
+      "faulty": "Неисправность"
+    },
+    "unknownVehicle": "Автомобиль не указан",
+    "unknownVehicleMarine": "Судно не указано",
+    "status": {
+      "pending": "Ждёт проверки",
+      "published": "Опубликована",
+      "rejected": "Не опубликована"
+    },
+    "hasRecording": "Файл PicoScope для скачивания",
+    "hasCsv": "Экспорт CSV для скачивания",
+    "kind": {
+      "good": "Эталон",
+      "faulty": "Неисправность"
+    },
+    "condition": {
+      "key_on": "Зажигание включено, двигатель заглушен",
+      "cranking": "Прокрутка стартером",
+      "idle": "Холостой ход",
+      "rpm_2500": "2500 об/мин",
+      "driving": "В движении",
+      "drivingMarine": "На ходу",
+      "under_load": "Под нагрузкой"
+    },
+    "chart": {
+      "label": "Осциллограмма {channels} за {span}",
+      "perDivision": "{value}/дел.",
+      "noSample": "нет данных",
+      "between": "от {lo} до {hi}"
+    },
+    "view": {
+      "label": "Показать",
+      "trace": "Осциллограмма",
+      "picture": "Снимок экрана"
+    },
+    "sharedOn": "Опубликовано {date}",
+    "reason": "Причина: {reason}",
+    "compare": {
+      "label": "Сравнить с",
+      "none": "Ничем",
+      "close": "Закрыть сравнение"
+    },
+    "note": "Примечания",
+    "writtenIn": "Написано на языке: {language}",
+    "files": {
+      "title": "Файлы",
+      "psdata": "Запись PicoScope",
+      "csv": "Экспорт CSV",
+      "pssettings": "Настройки осциллографа",
+      "openHint": "Откройте запись в PicoScope, чтобы увеличить, измерить и увидеть каждый канал."
+    },
+    "nothingToDraw": "Опубликована только запись. Скачайте её и откройте в PicoScope.",
+    "openPicture": "Открыть снимок в полном размере",
+    "close": "Закрыть",
+    "withdraw": "Отозвать",
+    "withdrawTitle": "Отозвать эту осциллограмму?",
+    "withdrawConfirm": "Она будет удалена вместе с файлами, и другие сервисы её не увидят.",
+    "withdrawn": "Осциллограмма отозвана",
+    "withdrawFailed": "Сейчас не удалось отозвать осциллограмму. Попробуйте ещё раз чуть позже.",
+    "shareDialog": {
+      "title": "Поделиться осциллограммой для {code}",
+      "description": "Другие сервисы увидят её после проверки сотрудником Torqvoice.",
+      "meaningHint": "В других блоках управления этот код означает другое. Ваша осциллограмма публикуется только для этого значения.",
+      "kind": "Что она показывает?",
+      "kindHint": {
+        "good": "Как это выглядит на исправной машине",
+        "goodMarine": "Как это выглядит на исправном двигателе",
+        "faulty": "Как это выглядело с неисправностью",
+        "faultyMarine": "Как это выглядело с неисправностью"
+      },
+      "measured": "Что вы измеряли?",
+      "measuredPlaceholder": "Датчик коленвала и впускной распредвал",
+      "measurements": {
+        "crank": "Датчик коленвала",
+        "camCrank": "Распредвал и коленвал",
+        "injector": "Форсунка",
+        "ignition": "Катушка зажигания",
+        "compression": "Относительная компрессия",
+        "lambda": "Лямбда-зонд",
+        "maf": "Датчик массового расхода воздуха",
+        "alternator": "Пульсации генератора",
+        "canBus": "Шина CAN",
+        "fuelPump": "Ток топливного насоса"
+      },
+      "condition": "Как работал двигатель? (необязательно)",
+      "files": "Файлы",
+      "filesHint": "Достаточно одного. Файл PicoScope другие откроют в PicoScope; экспорт CSV или снимок они увидят прямо здесь.",
+      "phone": {
+        "button": "Фото с телефона",
+        "title": "Сфотографируйте экран осциллографа",
+        "body": "Отсканируйте код камерой телефона и сделайте один снимок экрана. Он появится здесь, как только придёт.",
+        "received": "Фото с вашего телефона пришло"
+      },
+      "drop": "Перетащите сюда файл PicoScope или нажмите, чтобы выбрать",
+      "dropMore": "Добавить ещё файл",
+      "dropTypes": ".psdata, .pssettings, экспорт CSV или снимок экрана",
+      "role": {
+        "psdata": "Запись PicoScope",
+        "csv": "Экспорт CSV",
+        "image": "Снимок экрана",
+        "pssettings": "Настройки осциллографа"
+      },
+      "csvNotScope": "Это не экспорт осциллографа. Сохраните его заново из PicoScope в формате CSV.",
+      "remove": "Удалить {name}",
+      "fromPhone": "С вашего телефона",
+      "previewHint": "Так её увидят другие сервисы.",
+      "csvTip": "Совет: добавьте и экспорт CSV, чтобы другие видели осциллограмму здесь, не открывая PicoScope. В PicoScope выберите «Сохранить как» и CSV.",
+      "note": "Примечания (необязательно)",
+      "notePlaceholder": "Где измеряли, какой диапазон стоял и на что смотреть. Например: контакт 2 с обратной стороны разъёма датчика, пропуск зубьев виден раз за оборот.",
+      "notePlaceholderMarine": "Где измеряли, какой диапазон стоял и на что смотреть.",
+      "sending": "Отправка {percent} %",
+      "send": "Поделиться осциллограммой",
+      "sent": "Спасибо. Ваша осциллограмма ждёт проверки.",
+      "missing": {
+        "kind": "Выберите эталон или неисправность",
+        "title": "Укажите, что вы измеряли",
+        "file": "Добавьте файл или снимок",
+        "csv": "Удалите CSV, который не является экспортом осциллографа"
+      },
+      "notAScopeFile": "{name} не является файлом PicoScope, экспортом CSV или снимком",
+      "fileTooBig": "{name} больше {size}",
+      "errors": {
+        "failed": "Сейчас не удалось отправить вашу осциллограмму. Попробуйте ещё раз чуть позже.",
+        "photo": "Не удалось прочитать снимок. Попробуйте другой.",
+        "csv": "CSV не является экспортом осциллографа. Сохраните его заново из PicoScope в формате CSV.",
+        "too_large": "Файлы слишком большие.",
+        "no_file": "Добавьте запись, экспорт CSV или снимок.",
+        "paused": "Публикация осциллограмм сейчас приостановлена. Попробуйте позже.",
+        "demo": "В демо-версии публикация осциллограмм отключена."
+      }
+    }
+  },
+  "improve": {
+    "button": "Помогите нам стать лучше",
+    "title": "Помогите нам улучшить коды неисправностей",
+    "description": "Знаете, где марка публикует свои коды, заметили ошибку или есть идея? Напишите нам. Сообщение придёт прямо команде Torqvoice.",
+    "topic": "О чём это? (необязательно)",
+    "topics": {
+      "makes": "Каких кодов не хватает",
+      "wrong": "Что-то не так",
+      "source": "Хороший источник или ссылка",
+      "idea": "Идея"
+    },
+    "message": "Ваше сообщение",
+    "placeholders": {
+      "makes": "Где их найти, например ссылка на список кодов марки, и к каким моделям он относится.",
+      "wrong": "Какой код, что там написано и что должно быть. Ссылка на источник поможет.",
+      "source": "Вставьте ссылку и напишите, что в ней.",
+      "idea": "Что сделало бы коды неисправностей полезнее в вашем сервисе? Ссылки приветствуются."
+    },
+    "links": "Ссылки в вашем сообщении",
+    "about": "О коде {code}",
+    "leaveOut": "Убрать",
+    "replyHint": "Мы ответим на почту вашей учётной записи.",
+    "cancel": "Отмена",
+    "send": "Отправить",
+    "sent": "Спасибо. Ваше сообщение уже в пути к команде Torqvoice.",
+    "errors": {
+      "failed": "Сейчас не удалось отправить сообщение. Попробуйте ещё раз чуть позже.",
+      "limited": "Вы уже отправили несколько сообщений. Попробуйте через час."
+    }
+  },
+  "import": {
+    "button": "Импорт",
+    "title": "Импорт кодов неисправностей",
+    "description": "Добавьте собственные коды марки или больше сведений о кодах, которые у нас есть. Сотрудник Torqvoice проверяет каждый код, прежде чем его увидит какой-либо сервис.",
+    "yours": "Ваши импорты",
+    "codes": "{count, plural, one {# код} few {# кода} many {# кодов} other {# кода}}",
+    "status": {
+      "published": "опубликовано: {count}",
+      "pending": "ждут: {count}",
+      "rejected": "не опубликовано: {count}"
+    },
+    "examples": {
+      "title": "Начните с примера",
+      "sheet": "Пример таблицы",
+      "sheetHint": "Excel, одна строка на код",
+      "json": "Пример JSON",
+      "jsonHint": "Каждое поле подписано",
+      "columns": "Сохраните названия столбцов: Code, Title, Description, Common issues, How to fix, Good to know и Causes. Нужны только Code и Title. Каждую причину пишите с новой строки, а если знаете, добавьте после неё (high), (medium) или (low).",
+      "csv": "Скачать как CSV"
+    },
+    "make": "Марка",
+    "pickMake": "Выберите марку",
+    "language": "Язык текста",
+    "source": "Откуда это (необязательно)",
+    "sourcePlaceholder": "Ссылка или книга",
+    "checking": "Читаем и проверяем ваши коды",
+    "removeFile": "Убрать файл",
+    "drop": "Перетащите файл сюда или нажмите, чтобы выбрать",
+    "dropTypes": ".xlsx, .csv или .json, до 2000 кодов",
+    "fileProblems": {
+      "unreadable": "Не удалось прочитать файл. Сохраните его заново как .xlsx, .csv или .json.",
+      "empty": "В файле нет кодов.",
+      "no_code_column": "Не найден столбец Code. Начните с примера таблицы.",
+      "too_many": "Файл слишком большой: не более 2000 кодов и 5 МБ. Разделите его на части.",
+      "format": "Этот тип файла не поддерживается. Используйте .xlsx, .csv или .json.",
+      "legacy_xls": "Старые файлы .xls не поддерживаются. Сохраните его в Excel как .xlsx."
+    },
+    "counts": {
+      "new": "новые: {count}",
+      "change": "дополняют наши: {count}",
+      "problem": "нужно исправить: {count}",
+      "same": "уже такие же: {count}"
+    },
+    "rows": "Коды в файле",
+    "row": "Строка {n}",
+    "more": "и ещё {count}",
+    "problemsLeftOut": "Коды, которые нужно исправить, пропускаются. Исправьте их в файле и перетащите его снова или отправьте остальное сейчас.",
+    "rights": "Я могу этим делиться. Это не скопировано из платного или защищённого источника.",
+    "missing": {
+      "make": "Сначала выберите марку",
+      "file": "Добавьте файл",
+      "nothing": "Ничто в файле не дополняет то, что у нас есть",
+      "rights": "Отметьте флажок, чтобы отправить"
+    },
+    "reviewHint": "Ничего не публикуется, пока это не проверит человек.",
+    "cancel": "Отмена",
+    "send": "{count, plural, one {Отправить # код на проверку} few {Отправить # кода на проверку} many {Отправить # кодов на проверку} other {Отправить # кода на проверку}}",
+    "sendNone": "Отправить на проверку",
+    "sent": "{count, plural, one {Отправлено. # код ждёт проверки.} few {Отправлено. # кода ждут проверки.} many {Отправлено. # кодов ждут проверки.} other {Отправлено. # кода ждут проверки.}}",
+    "errors": {
+      "failed": "Сейчас не удалось проверить или отправить файл. Попробуйте ещё раз чуть позже.",
+      "paused": "Отправка кодов сейчас приостановлена. Попробуйте позже.",
+      "nothing": "Ничто в файле не дополняет то, что у нас есть.",
+      "demo": "В демо-версии импорт кодов отключён.",
+      "rights": "Отметьте, что вы можете этим делиться.",
+      "too_large": "Файл слишком большой: не более 2000 кодов и 5 МБ."
+    },
+    "problems": {
+      "code": "Это не код, который использует {make}",
+      "duplicate": "Один и тот же код дважды в файле",
+      "title": "Нужно название",
+      "empty": "Ничего не написано",
+      "too_long": "Слишком длинно: название до 200 символов, каждый текст до 4000",
+      "likelihood": "Вероятность должна быть low, medium или high",
+      "meanings": "{make} использует этот код для нескольких вещей. Лучше поделитесь этим на странице кода."
+    },
+    "state": {
+      "new": "Новый",
+      "change": "Добавляет: {what}"
+    },
+    "fields": {
+      "title": "название",
+      "description": "значение",
+      "common_issues": "частые проблемы",
+      "resolution": "как устранить",
+      "advisory": "полезно знать",
+      "causes": "причины"
+    },
+    "addsCauses": "{count, plural, one {# причину} few {# причины} many {# причин} other {# причины}}",
+    "searchMake": "Поиск марки",
+    "noMake": "Марка не найдена",
+    "useMake": "Использовать {name}",
+    "groups": {
+      "cars": "Легковые и фургоны",
+      "marine": "Лодки и моторы"
+    }
+  },
+  "makeCodes": {
+    "heading": "Марки со своим значением для {code}",
+    "thisCar": "Марка этого автомобиля"
   }
   }
 }
 }

+ 351 - 3
messages/tr/faultCodes.json

@@ -31,6 +31,9 @@
     "manufacturer": "Üretici bilgileri",
     "manufacturer": "Üretici bilgileri",
     "related": "Diğer kodlarla birlikte",
     "related": "Diğer kodlarla birlikte",
     "diagnosis": "Teşhis ipucu",
     "diagnosis": "Teşhis ipucu",
+    "common_issues": "Sık görülen sorunlar",
+    "resolution": "Çözüm",
+    "advisory": "Tavsiye",
     "other": "Bilmekte yarar var"
     "other": "Bilmekte yarar var"
   },
   },
   "detail": {
   "detail": {
@@ -99,6 +102,8 @@
     "model": "Model",
     "model": "Model",
     "year": "Yıl",
     "year": "Yıl",
     "vehicleHint": "Yalnızca marka, model, yıl ve motor paylaşılır; plaka, şasi numarası (VIN), müşteri veya atölyenizin adı asla paylaşılmaz.",
     "vehicleHint": "Yalnızca marka, model, yıl ve motor paylaşılır; plaka, şasi numarası (VIN), müşteri veya atölyenizin adı asla paylaşılmaz.",
+    "meaning": "Anlam",
+    "meaningHint": "Bu kod diğer kontrol ünitelerinde başka bir anlama gelir. Çözümünüz yalnızca bu anlam için paylaşılır.",
     "vehicleHintMarine": "Yalnızca marka, model, yıl ve motor paylaşılır; sicil numarası, gövde numarası, müşteri veya atölyenizin adı asla paylaşılmaz.",
     "vehicleHintMarine": "Yalnızca marka, model, yıl ve motor paylaşılır; sicil numarası, gövde numarası, müşteri veya atölyenizin adı asla paylaşılmaz.",
     "send": "Çözümü paylaş",
     "send": "Çözümü paylaş",
     "sent": "Teşekkürler. Çözümünüz inceleme bekliyor.",
     "sent": "Teşekkürler. Çözümünüz inceleme bekliyor.",
@@ -178,7 +183,9 @@
     "add": "{count, plural, =0 {Kod ekle} one {# kod ekle} other {# kod ekle}}",
     "add": "{count, plural, =0 {Kod ekle} one {# kod ekle} other {# kod ekle}}",
     "added": "{count, plural, one {# arıza kodu eklendi} other {# arıza kodu eklendi}}",
     "added": "{count, plural, one {# arıza kodu eklendi} other {# arıza kodu eklendi}}",
     "skipped": "{count, plural, one {# kod bu işte zaten vardı} other {# kod bu işte zaten vardı}}",
     "skipped": "{count, plural, one {# kod bu işte zaten vardı} other {# kod bu işte zaten vardı}}",
-    "failed": "Kodlar eklenemedi"
+    "failed": "Kodlar eklenemedi",
+    "descriptionOem": "Arıza tespit cihazındaki kodları yazın veya yapıştırın: P0420 gibi OBD kodları ya da 2A82 gibi BMW kodları. Her biri bu işte, anlamı kaydedilmiş bir gözlem olur.",
+    "placeholderOem": "P0420, 2A82 kayıtlı"
   },
   },
   "finding": {
   "finding": {
     "seenBefore": "Görüldü: {date}",
     "seenBefore": "Görüldü: {date}",
@@ -218,7 +225,9 @@
   },
   },
   "notice": {
   "notice": {
     "title": "Arıza kodları yeni",
     "title": "Arıza kodları yeni",
-    "body": "Sürekli yeni kodlar ekliyoruz, bu yüzden henüz her kod burada değil. Şimdilik standart OBD-II kodları var; üreticiye özel kodlar henüz eklenmedi."
+    "body": "Standart OBD-II kodları burada. Markaların kendi kodlarını eklemek için çalışıyoruz ve sürekli yeni kodlar ekleniyor. Bir markanın kodlarını nerede bulacağınızı biliyor musunuz? “Gelişmemize yardım edin” ile bize bildirin.",
+    "bodyBmw": "Standart OBD-II kodları ve MINI ile Rolls-Royce'un da paylaştığı BMW'ye özel kodlar burada. Daha fazla marka eklemek için çalışıyoruz ve sürekli yeni kodlar ekleniyor. Bir markanın kodlarını nerede bulacağınızı biliyor musunuz? “Gelişmemize yardım edin” ile bize bildirin.",
+    "bodyMakes": "Standart OBD-II kodları ve şu markaların kendi kodları burada: {makes}. Daha fazla marka eklemek için çalışıyoruz ve sürekli yeni kodlar ekleniyor. Bir markanın kodlarını nerede bulacağınızı biliyor musunuz? “Gelişmemize yardım edin” ile bize bildirin."
   },
   },
   "support": {
   "support": {
     "button": "Destekle iletişime geç",
     "button": "Destekle iletişime geç",
@@ -245,6 +254,345 @@
     "done": "Bitirdiğinizde bilgisayara dönün ve çözümü paylaşın.",
     "done": "Bitirdiğinizde bilgisayara dönün ve çözümü paylaşın.",
     "privacy": "Yüzleri, plakaları ve müşteri bilgilerini fotoğrafın dışında tutun.",
     "privacy": "Yüzleri, plakaları ve müşteri bilgilerini fotoğrafın dışında tutun.",
     "expired": "Bu kodun süresi doldu. Bilgisayarda yeni bir kod gösterin.",
     "expired": "Bu kodun süresi doldu. Bilgisayarda yeni bir kod gösterin.",
-    "invalid": "Bu kod çalışmıyor. Bilgisayarda yeni bir kod gösterin."
+    "invalid": "Bu kod çalışmıyor. Bilgisayarda yeni bir kod gösterin.",
+    "waveform": {
+      "title": "Osiloskop ekranının fotoğrafı",
+      "forCode": "bilgisayarda paylaştığınız dalga formu için",
+      "intro": "Osiloskop ekranının bir fotoğrafını çekin veya seçin. Bilgisayarda görünür.",
+      "full": "Bir dalga formunda bir fotoğraf olur. Yenisini çekmek için bilgisayarda kaldırın.",
+      "done": "Bilgisayara dönün ve dalga formunu paylaşın.",
+      "added": "Fotoğraf eklendi"
+    }
+  },
+  "oem": {
+    "badge": "{make} kodu",
+    "summary": "{count, plural, one {# anlam} other {# anlam}}. Bir BMW kodu her kontrol ünitesinde farklı bir anlama gelebilir.",
+    "matches": "Bu araca uyuyor",
+    "noMatch": "Bu anlamların hiçbiri bu aracın motoru için listelenmemiş. Cihazınızın kodu hangi kontrol ünitesinden okuduğunu kontrol edin.",
+    "inEnglish": "{make} İngilizce metni: {text}",
+    "bmwText": "{make} metni: {text}",
+    "units": "Kontrol üniteleri",
+    "moreUnits": "+{count} daha",
+    "otherChassis": "Diğer şasiler",
+    "years": "{from}–{to}",
+    "yearsFrom": "{from} yılından beri",
+    "source": "Anlamlar BMW’nin kendi teşhis metinlerinden gelir, çoğu Almanca.",
+    "notServed": "{make} kodları henüz kullanılamıyor.",
+    "notFound": "{code} {make} verilerinde yok.",
+    "whereUsed": "{make} nerede kullanıyor",
+    "models": "Modeller",
+    "engineList": "Motorlar",
+    "fewerUnits": "Daha az göster",
+    "fitsThisCar": "Bu araç",
+    "meanings": "Anlamlar",
+    "otherMeanings": "ve {count} anlam daha"
+  },
+  "research": {
+    "inviteTitle": "Çevrimiçi çözüm ara",
+    "invite": "{code} için üretici belgelerini, servis bültenlerini ve atölye forumlarını tarıyoruz. Yalnızca kaynaklı ve somut bir yanıt saklanır ve Torqvoice'tan bir kişi onu kontrol eder.",
+    "button": "Çözüm ara",
+    "working": "{code} araştırılıyor",
+    "steps": {
+      "bulletins": "Üretici belgeleri ve servis bültenleri taranıyor",
+      "forums": "Onaylanmış çözümler için atölye forumları okunuyor",
+      "agree": "Kaynakların örtüştüğü kontrol ediliyor",
+      "writing": "Adımlar yazılıyor"
+    },
+    "workingHint": "Bu bir iki dakika sürer. Bulunanlar tüm atölyeler için kodda kalır.",
+    "runningTitle": "Şu anda araştırılıyor",
+    "runningHint": "Başka bir atölye az önce istedi. Bir dakika sonra yeniden bakın.",
+    "checkAgain": "Yeniden bak",
+    "title": "Araştırılmış çözüm",
+    "pending": "Bir kişinin kontrolünü bekliyor",
+    "published": "Torqvoice tarafından kontrol edildi",
+    "pendingHint": "Aşağıdaki kaynaklardan yapay zekâ tarafından bulundu. Parça değiştirmeden önce araç üzerinde kontrol edin.",
+    "sources": "Kaynaklar",
+    "helpfulQuestion": "Bu sorunu çözdü mü?",
+    "helpfulYes": "Evet, çözdü",
+    "helpfulNo": "Hayır, çözmedi",
+    "thanks": "Teşekkürler, not edildi",
+    "missedTitle": "Çevrimiçi güvenilir bir çözüm bulunamadı",
+    "missed": "{code} hakkında somut bir şey çıkmadı, bu yüzden hiçbir şey kaydedilmedi. Atölyelerin bu koda ihtiyaç duyduğunu not ettik.",
+    "searchedOn": "{date} tarihinde arandı",
+    "shareInstead": "Çözümünüzü paylaşın",
+    "errors": {
+      "busy": "Bu kod şu anda araştırılıyor. Bir dakika sonra yeniden bakın.",
+      "limited": "Atölyeniz bugünkü araştırma hakkını kullandı. Yarın yeniden deneyin.",
+      "failed": "Araştırma şu anda yapılamadı. Daha sonra yeniden deneyin."
+    }
+  },
+  "waveforms": {
+    "title": "Dalga formları",
+    "description": "Diğer servislerin osiloskop kayıtları, sağlam ve arızalı, kendi ölçümünüzle karşılaştırmak için.",
+    "share": "Dalga formu paylaş",
+    "unavailable": "Dalga formları şu anda kullanılamıyor.",
+    "empty": "{code} için henüz dalga formu yok",
+    "emptyHint": "Sağlam bir kaydınız var mı? Paylaşın, bu kodu kovalayan bir sonraki teknisyenin karşılaştıracak bir şeyi olsun.",
+    "filter": {
+      "label": "Göster",
+      "all": "Tümü",
+      "good": "Sağlam",
+      "faulty": "Arızalı"
+    },
+    "unknownVehicle": "Araç belirtilmedi",
+    "unknownVehicleMarine": "Tekne belirtilmedi",
+    "status": {
+      "pending": "İnceleme bekliyor",
+      "published": "Yayında",
+      "rejected": "Yayınlanmadı"
+    },
+    "hasRecording": "İndirilecek PicoScope dosyası",
+    "hasCsv": "İndirilecek CSV dışa aktarımı",
+    "kind": {
+      "good": "Sağlam",
+      "faulty": "Arızalı"
+    },
+    "condition": {
+      "key_on": "Kontak açık, motor kapalı",
+      "cranking": "Marş",
+      "idle": "Rölanti",
+      "rpm_2500": "2500 dev/dak",
+      "driving": "Sürüş sırasında",
+      "drivingMarine": "Seyir sırasında",
+      "under_load": "Yük altında"
+    },
+    "chart": {
+      "label": "{channels} dalga formu, {span} boyunca",
+      "perDivision": "{value}/böl.",
+      "noSample": "örnek yok",
+      "between": "{lo} ile {hi} arası"
+    },
+    "view": {
+      "label": "Göster",
+      "trace": "Dalga formu",
+      "picture": "Ekran fotoğrafı"
+    },
+    "sharedOn": "{date} tarihinde paylaşıldı",
+    "reason": "Neden: {reason}",
+    "compare": {
+      "label": "Şununla karşılaştır",
+      "none": "Hiçbiri",
+      "close": "Karşılaştırmayı kapat"
+    },
+    "note": "Notlar",
+    "writtenIn": "Dili: {language}",
+    "files": {
+      "title": "Dosyalar",
+      "psdata": "PicoScope kaydı",
+      "csv": "CSV dışa aktarımı",
+      "pssettings": "Osiloskop ayarları",
+      "openHint": "Yakınlaştırmak, ölçmek ve her kanalı görmek için kaydı PicoScope'ta açın."
+    },
+    "nothingToDraw": "Yalnızca kayıt paylaşıldı. PicoScope'ta açmak için indirin.",
+    "openPicture": "Fotoğrafı tam boyutta aç",
+    "close": "Kapat",
+    "withdraw": "Geri çek",
+    "withdrawTitle": "Bu dalga formu geri çekilsin mi?",
+    "withdrawConfirm": "Dosyalarıyla birlikte silinir ve başka hiçbir servis onu görmez.",
+    "withdrawn": "Dalga formu geri çekildi",
+    "withdrawFailed": "Dalga formu şu anda geri çekilemedi. Biraz sonra tekrar deneyin.",
+    "shareDialog": {
+      "title": "{code} için dalga formu paylaş",
+      "description": "Torqvoice'tan biri kontrol ettikten sonra diğer servisler görür.",
+      "meaningHint": "Bu kod başka kontrol ünitelerinde başka bir anlama gelir. Dalga formunuz yalnızca bu anlam için paylaşılır.",
+      "kind": "Ne gösteriyor?",
+      "kindHint": {
+        "good": "Sağlam bir araçta nasıl görünür",
+        "goodMarine": "Sağlam bir motorda nasıl görünür",
+        "faulty": "Arızayla nasıl görünüyordu",
+        "faultyMarine": "Arızayla nasıl görünüyordu"
+      },
+      "measured": "Neyi ölçtünüz?",
+      "measuredPlaceholder": "Krank sensörü ve emme eksantrik mili",
+      "measurements": {
+        "crank": "Krank sensörü",
+        "camCrank": "Eksantrik ve krank mili",
+        "injector": "Enjektör",
+        "ignition": "Ateşleme bobini",
+        "compression": "Göreli kompresyon",
+        "lambda": "Lambda sensörü",
+        "maf": "Hava akış metre",
+        "alternator": "Alternatör dalgalanması",
+        "canBus": "CAN hattı",
+        "fuelPump": "Yakıt pompası akımı"
+      },
+      "condition": "Motor nasıl çalışıyordu? (isteğe bağlı)",
+      "files": "Dosyalar",
+      "filesHint": "Biri yeterli. PicoScope dosyasını diğerleri PicoScope'ta açar; CSV dışa aktarımı veya fotoğrafı burada görür.",
+      "phone": {
+        "button": "Telefondan fotoğraf",
+        "title": "Osiloskop ekranının fotoğrafını çekin",
+        "body": "Kodu telefonunuzun kamerasıyla tarayın ve ekranın bir fotoğrafını çekin. Gelir gelmez burada görünür.",
+        "received": "Telefonunuzdan fotoğraf geldi"
+      },
+      "drop": "PicoScope dosyanızı buraya bırakın veya seçmek için tıklayın",
+      "dropMore": "Başka dosya ekle",
+      "dropTypes": ".psdata, .pssettings, bir CSV dışa aktarımı veya ekran fotoğrafı",
+      "role": {
+        "psdata": "PicoScope kaydı",
+        "csv": "CSV dışa aktarımı",
+        "image": "Ekran fotoğrafı",
+        "pssettings": "Osiloskop ayarları"
+      },
+      "csvNotScope": "Bu bir osiloskop dışa aktarımı değil. PicoScope'tan CSV olarak yeniden kaydedin.",
+      "remove": "{name} kaldır",
+      "fromPhone": "Telefonunuzdan",
+      "previewHint": "Diğer servisler bunu böyle görecek.",
+      "csvTip": "İpucu: bir CSV dışa aktarımı da ekleyin, böylece diğerleri dalga formunu PicoScope'u açmadan burada görür. PicoScope'ta Farklı kaydet'i seçip CSV'yi seçin.",
+      "note": "Notlar (isteğe bağlı)",
+      "notePlaceholder": "Nereden ölçtüğünüz, kullandığınız ölçek ve nelere bakılacağı. Örneğin: sensör soketinde 2 numaralı pin arkadan ölçüldü, eksik diş boşluğu her turda bir kez görünüyor.",
+      "notePlaceholderMarine": "Nereden ölçtüğünüz, kullandığınız ölçek ve nelere bakılacağı.",
+      "sending": "Gönderiliyor %{percent}",
+      "send": "Dalga formunu paylaş",
+      "sent": "Teşekkürler. Dalga formunuz inceleme bekliyor.",
+      "missing": {
+        "kind": "Sağlam veya arızalı seçin",
+        "title": "Neyi ölçtüğünüzü yazın",
+        "file": "Bir dosya veya fotoğraf ekleyin",
+        "csv": "Osiloskop dışa aktarımı olmayan CSV'yi kaldırın"
+      },
+      "notAScopeFile": "{name} bir PicoScope dosyası, CSV dışa aktarımı veya fotoğraf değil",
+      "fileTooBig": "{name}, {size} boyutundan büyük",
+      "errors": {
+        "failed": "Dalga formunuz şu anda gönderilemedi. Biraz sonra tekrar deneyin.",
+        "photo": "Fotoğraf okunamadı. Başka birini deneyin.",
+        "csv": "CSV bir osiloskop dışa aktarımı değil. PicoScope'tan CSV olarak yeniden kaydedin.",
+        "too_large": "Dosyalar çok büyük.",
+        "no_file": "Kaydı, bir CSV dışa aktarımını veya bir fotoğraf ekleyin.",
+        "paused": "Dalga formu paylaşımı şu anda duraklatıldı. Daha sonra tekrar deneyin.",
+        "demo": "Demoda dalga formu paylaşımı kapalı."
+      }
+    }
+  },
+  "improve": {
+    "button": "Gelişmemize yardım edin",
+    "title": "Arıza kodlarını geliştirmemize yardım edin",
+    "description": "Bir markanın kendi kodlarını nerede yayınladığını biliyor musunuz, bir hata mı gördünüz ya da bir fikriniz mi var? Bize yazın. Doğrudan Torqvoice ekibine gider.",
+    "topic": "Konu nedir? (isteğe bağlı)",
+    "topics": {
+      "makes": "Eksik kodlar",
+      "wrong": "Bir şey yanlış",
+      "source": "İyi bir kaynak veya bağlantı",
+      "idea": "Bir fikir"
+    },
+    "message": "Mesajınız",
+    "placeholders": {
+      "makes": "Nerede bulunduğu, örneğin markanın kod listesine bir bağlantı, ve hangi modelleri kapsadığı.",
+      "wrong": "Hangi kod, ne yazıyor ve ne yazmalı. Bulduğunuz yere bir bağlantı yardımcı olur.",
+      "source": "Bağlantıyı yapıştırın ve içinde ne olduğunu yazın.",
+      "idea": "Arıza kodlarını servisinizde daha faydalı ne yapardı? Bağlantılar memnuniyetle karşılanır."
+    },
+    "links": "Mesajınızdaki bağlantılar",
+    "about": "{code} hakkında",
+    "leaveOut": "Çıkar",
+    "replyHint": "Hesabınızdaki e-posta adresine yanıt veririz.",
+    "cancel": "İptal",
+    "send": "Gönder",
+    "sent": "Teşekkürler. Mesajınız Torqvoice ekibine gidiyor.",
+    "errors": {
+      "failed": "Mesajınız şu anda gönderilemedi. Biraz sonra tekrar deneyin.",
+      "limited": "Zaten birkaç mesaj gönderdiniz. Bir saat sonra tekrar deneyin."
+    }
+  },
+  "import": {
+    "button": "İçe aktar",
+    "title": "Arıza kodlarını içe aktar",
+    "description": "Bir markanın kendi kodlarını veya elimizdeki kodlar hakkında daha fazlasını ekleyin. Hiçbir servis görmeden önce Torqvoice'tan biri her kodu kontrol eder.",
+    "yours": "İçe aktarımlarınız",
+    "codes": "{count, plural, one {# kod} other {# kod}}",
+    "status": {
+      "published": "{count} yayında",
+      "pending": "{count} bekliyor",
+      "rejected": "{count} yayınlanmadı"
+    },
+    "examples": {
+      "title": "Bir örnekle başlayın",
+      "sheet": "Örnek tablo",
+      "sheetHint": "Excel, her kod için bir satır",
+      "json": "Örnek JSON",
+      "jsonHint": "Her alan adlandırılmış",
+      "columns": "Sütun adlarını koruyun: Code, Title, Description, Common issues, How to fix, Good to know ve Causes. Yalnızca Code ve Title gerekir. Her nedeni ayrı satıra yazın; biliyorsanız sonuna (high), (medium) veya (low) ekleyin.",
+      "csv": "CSV olarak indir"
+    },
+    "make": "Marka",
+    "pickMake": "Bir marka seçin",
+    "language": "Metnin dili",
+    "source": "Nereden geldiği (isteğe bağlı)",
+    "sourcePlaceholder": "Bağlantı veya kitap",
+    "checking": "Kodlarınız okunuyor ve kontrol ediliyor",
+    "removeFile": "Dosyayı kaldır",
+    "drop": "Dosyanızı buraya bırakın veya seçmek için tıklayın",
+    "dropTypes": ".xlsx, .csv veya .json, en fazla 2.000 kod",
+    "fileProblems": {
+      "unreadable": "Dosya okunamadı. .xlsx, .csv veya .json olarak yeniden kaydedin.",
+      "empty": "Dosyada kod yok.",
+      "no_code_column": "Code sütunu bulunamadı. Örnek tabloyla başlayın.",
+      "too_many": "Dosya çok büyük: en fazla 2.000 kod ve 5 MB. Parçalara bölün.",
+      "format": "Bu dosya türü desteklenmiyor. .xlsx, .csv veya .json kullanın.",
+      "legacy_xls": "Eski .xls dosyaları desteklenmiyor. Excel'de .xlsx olarak kaydedin."
+    },
+    "counts": {
+      "new": "{count} yeni",
+      "change": "{count} bizimkini tamamlıyor",
+      "problem": "{count} düzeltilmeli",
+      "same": "{count} zaten aynı"
+    },
+    "rows": "Dosyadaki kodlar",
+    "row": "Satır {n}",
+    "more": "ve {count} tane daha",
+    "problemsLeftOut": "Düzeltilmesi gereken kodlar dışarıda bırakılır. Dosyada düzeltip yeniden bırakın veya geri kalanını şimdi gönderin.",
+    "rights": "Bunu paylaşabilirim. Ücretli veya korumalı bir kaynaktan kopyalanmadı.",
+    "missing": {
+      "make": "Önce markayı seçin",
+      "file": "Dosyanızı ekleyin",
+      "nothing": "Dosyadaki hiçbir şey elimizdekini tamamlamıyor",
+      "rights": "Göndermek için kutuyu işaretleyin"
+    },
+    "reviewHint": "Bir kişi kontrol etmeden hiçbir şey yayınlanmaz.",
+    "cancel": "İptal",
+    "send": "{count, plural, one {# kodu incelemeye gönder} other {# kodu incelemeye gönder}}",
+    "sendNone": "İncelemeye gönder",
+    "sent": "{count, plural, one {Gönderildi. # kod inceleme bekliyor.} other {Gönderildi. # kod inceleme bekliyor.}}",
+    "errors": {
+      "failed": "Dosyanız şu anda kontrol edilemedi veya gönderilemedi. Biraz sonra tekrar deneyin.",
+      "paused": "Kod gönderimi şu anda duraklatıldı. Daha sonra tekrar deneyin.",
+      "nothing": "Dosyadaki hiçbir şey elimizdekini tamamlamıyor.",
+      "demo": "Demoda kod içe aktarma kapalı.",
+      "rights": "Bunu paylaşabileceğinizi belirtmek için kutuyu işaretleyin.",
+      "too_large": "Dosya çok büyük: en fazla 2.000 kod ve 5 MB."
+    },
+    "problems": {
+      "code": "{make} tarafından kullanılan bir kod değil",
+      "duplicate": "Aynı kod dosyada iki kez",
+      "title": "Bir başlık gerekiyor",
+      "empty": "Hiçbir şey yazılmamış",
+      "too_long": "Çok uzun: başlık en fazla 200 karakter, her metin en fazla 4.000",
+      "likelihood": "Olasılık low, medium veya high olmalı",
+      "meanings": "{make} bu kodu birden fazla şey için kullanıyor. Bunun yerine kodun sayfasında paylaşın."
+    },
+    "state": {
+      "new": "Yeni",
+      "change": "Ekler: {what}"
+    },
+    "fields": {
+      "title": "başlık",
+      "description": "anlamı",
+      "common_issues": "sık sorunlar",
+      "resolution": "nasıl giderilir",
+      "advisory": "bilmekte yarar var",
+      "causes": "nedenler"
+    },
+    "addsCauses": "{count, plural, one {# neden} other {# neden}}",
+    "searchMake": "Marka ara",
+    "noMake": "Marka bulunamadı",
+    "useMake": "{name} kullan",
+    "groups": {
+      "cars": "Otomobiller ve hafif ticariler",
+      "marine": "Tekneler ve motorlar"
+    }
+  },
+  "makeCodes": {
+    "heading": "{code} için kendi anlamı olan markalar",
+    "thisCar": "Bu aracın markası"
   }
   }
 }
 }

+ 328 - 0
src/__tests__/features/fault-codes/code-import.test.tsx

@@ -0,0 +1,328 @@
+import ExcelJS from 'exceljs'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  CodeFileError,
+  EXAMPLE_HEADERS,
+  EXAMPLE_ROWS,
+  exampleJson,
+  exampleSheetRows,
+  readCauses,
+  readCodeFile,
+} from '@/features/fault-codes/Lib/codeImportFile.server'
+
+/**
+ * Importing fault codes from a file: the spreadsheet or JSON a workshop fills in from the
+ * example is read into codes, each code is checked before anything is sent, and only what is
+ * new or adds to ours goes for review, once the sender says they may share it.
+ */
+
+const h = vi.hoisted(() => ({ imports: vi.fn(), track: vi.fn() }))
+vi.mock('@/lib/analytics', async (original) => ({
+  ...(await original<typeof import('@/lib/analytics')>()),
+  track: h.track,
+}))
+// Radix measures the picker's anchor and cmdk scrolls to the active item; jsdom does neither.
+class NoResize {
+  observe = vi.fn()
+  unobserve = vi.fn()
+  disconnect = vi.fn()
+}
+Element.prototype.scrollIntoView = vi.fn()
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
+vi.mock('@/features/fault-codes/Actions/getCodeImports', () => ({ getCodeImports: h.imports }))
+
+async function workbook(rows: string[][]): Promise<Buffer> {
+  const book = new ExcelJS.Workbook()
+  const sheet = book.addWorksheet('Codes')
+  for (const row of rows) sheet.addRow(row)
+  return Buffer.from(await book.xlsx.writeBuffer())
+}
+
+describe('reading a file of codes', () => {
+  it('reads the example spreadsheet back into the example codes', async () => {
+    const file = await readCodeFile(
+      await workbook([[...EXAMPLE_HEADERS], ...exampleSheetRows()]),
+      'ford.xlsx'
+    )
+    expect(file.rows).toEqual(
+      EXAMPLE_ROWS.map((r) => ({
+        description: undefined,
+        commonIssues: undefined,
+        resolution: undefined,
+        advisory: undefined,
+        ...r,
+      }))
+    )
+  })
+
+  it('reads the example JSON, with what it says of itself', async () => {
+    const file = await readCodeFile(Buffer.from(exampleJson()), 'codes.json')
+    expect(file.make).toBe('Ford')
+    expect(file.language).toBe('en')
+    expect(file.rows[0]).toMatchObject({
+      code: 'P1000',
+      resolution: EXAMPLE_ROWS[0].resolution,
+      advisory: EXAMPLE_ROWS[0].advisory,
+      causes: EXAMPLE_ROWS[0].causes,
+    })
+    // A bare list of codes reads too.
+    const list = await readCodeFile(
+      Buffer.from(JSON.stringify([{ dtc: 'P1001', name: 'KOER test not complete' }])),
+      'list.json'
+    )
+    expect(list.rows).toEqual([
+      expect.objectContaining({ code: 'P1001', title: 'KOER test not complete', causes: [] }),
+    ])
+  })
+
+  it('knows a column by other words, and causes by line or semicolon', async () => {
+    const csv = [
+      'DTC;Short text;How to fix it;Possible causes',
+      'P1260;Theft detected;Code the key again;"Key not coded (HIGH)\nTransponder broken"',
+      'P1261;Other;;"Wiring (low); Module"',
+    ].join('\r\n')
+    const file = await readCodeFile(Buffer.from(csv), 'codes.csv')
+    expect(file.rows[0]).toMatchObject({
+      code: 'P1260',
+      title: 'Theft detected',
+      resolution: 'Code the key again',
+      causes: [{ label: 'Key not coded', likelihood: 'high' }, { label: 'Transponder broken' }],
+    })
+    expect(file.rows[1].causes).toEqual([
+      { label: 'Wiring', likelihood: 'low' },
+      { label: 'Module' },
+    ])
+    expect(readCauses('- Spark plug (medium)')).toEqual([
+      { label: 'Spark plug', likelihood: 'medium' },
+    ])
+  })
+
+  it('says what is wrong with a file that is not one', async () => {
+    const problem = (p: Promise<unknown>) =>
+      p.then(
+        () => null,
+        (e) => (e instanceof CodeFileError ? e.problem : 'other')
+      )
+    expect(await problem(readCodeFile(Buffer.from('name,price\nbolt,2\n'), 'parts.csv'))).toBe(
+      'no_code_column'
+    )
+    expect(await problem(readCodeFile(Buffer.from('{ not json'), 'codes.json'))).toBe('unreadable')
+    expect(await problem(readCodeFile(Buffer.from('{"codes": 3}'), 'codes.json'))).toBe('format')
+    expect(await problem(readCodeFile(Buffer.from('x'), 'codes.xls'))).toBe('legacy_xls')
+    expect(await problem(readCodeFile(Buffer.from('[]'), 'codes.json'))).toBe('empty')
+  })
+})
+
+const { ImportCodesDialog } = await import('@/features/fault-codes/Components/ImportCodesDialog')
+const fetchMock = vi.fn()
+
+function reply(body: unknown) {
+  return Promise.resolve(new Response(JSON.stringify(body)))
+}
+
+const CHECKED = {
+  success: true,
+  data: {
+    counts: { new: 1, change: 1, same: 1, problem: 1 },
+    rows: [
+      {
+        n: 1,
+        code: 'P1000',
+        title: 'OBD readiness tests not complete',
+        state: 'new',
+        problem: null,
+        fields: ['title', 'description'],
+        addedCauses: 1,
+      },
+      {
+        n: 2,
+        code: 'X',
+        title: 'Not a code',
+        state: 'problem',
+        problem: 'code',
+        fields: [],
+        addedCauses: 0,
+      },
+      {
+        n: 3,
+        code: 'P1260',
+        title: null,
+        state: 'change',
+        problem: null,
+        fields: ['resolution', 'causes'],
+        addedCauses: 2,
+      },
+      {
+        n: 4,
+        code: 'P1261',
+        title: 'Same as ours',
+        state: 'same',
+        problem: null,
+        fields: [],
+        addedCauses: 0,
+      },
+    ],
+    meta: { make: 'Ford', language: 'en', source: null },
+  },
+}
+
+describe('the import dialog', () => {
+  beforeEach(() => {
+    fetchMock.mockReset()
+    vi.stubGlobal('fetch', fetchMock)
+    vi.stubGlobal('ResizeObserver', NoResize)
+    h.imports.mockResolvedValue({
+      success: true,
+      data: [
+        {
+          id: 'a',
+          make: 'ford',
+          lang: 'en',
+          source: null,
+          fileName: 'old.xlsx',
+          createdAt: '2026-11-01T10:00:00Z',
+          counts: { new: 30, change: 2, pending: 2, published: 30, rejected: 0 },
+          reviewedAt: '2026-11-02T10:00:00Z',
+        },
+      ],
+    })
+    vi.mocked(toast.success).mockReset()
+    vi.mocked(toast.error).mockReset()
+  })
+  afterEach(() => vi.unstubAllGlobals())
+
+  it('offers the examples, checks a dropped file and sends what adds to ours', async () => {
+    render(<ImportCodesDialog open onOpenChange={vi.fn()} />)
+    const dialog = within(screen.getByRole('dialog'))
+    expect(dialog.getByRole('link', { name: /Example spreadsheet/ })).toHaveAttribute(
+      'href',
+      '/api/protected/fault-codes/import/example?format=xlsx'
+    )
+    expect(dialog.getByRole('link', { name: /Example JSON/ })).toHaveAttribute(
+      'href',
+      '/api/protected/fault-codes/import/example?format=json'
+    )
+    // Earlier imports and how far their review has come.
+    await waitFor(() => expect(dialog.getByText('Your imports')).toBeInTheDocument())
+    expect(dialog.getByText(/30 published · 2 waiting/)).toBeInTheDocument()
+    expect(dialog.getByText('Pick the make first')).toBeInTheDocument()
+
+    // A JSON file names its make, so the check runs at once.
+    fetchMock.mockImplementationOnce(() => reply(CHECKED))
+    const file = new File([exampleJson()], 'ford.json', { type: 'application/json' })
+    fireEvent.change(dialog.getByTestId('import-file-input'), { target: { files: [file] } })
+    await waitFor(() => expect(dialog.getByText('1 new')).toBeInTheDocument())
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('/api/protected/fault-codes/import/check')
+    expect((init.body as FormData).get('make')).toBe('ford')
+    expect(dialog.getByText('1 add to ours')).toBeInTheDocument()
+    expect(h.track).toHaveBeenCalledWith('fault_code:import_check', {
+      make: 'ford',
+      file_type: 'json',
+      rows: 4,
+      new: 1,
+      change: 1,
+      same: 1,
+      problem: 1,
+    })
+    expect(dialog.getByText('1 need a fix')).toBeInTheDocument()
+    expect(dialog.getByText('1 already the same')).toBeInTheDocument()
+    // Problems first, in words; a change says what it adds.
+    const rows = within(dialog.getByRole('list', { name: 'Codes in the file' })).getAllByRole(
+      'listitem'
+    )
+    expect(rows[0]).toHaveTextContent('Row 2: Not a code Ford uses')
+    expect(rows[2]).toHaveTextContent('Adds how to fix and 2 causes')
+    expect(dialog.queryByText('Same as ours')).not.toBeInTheDocument()
+
+    const send = dialog.getByRole('button', { name: 'Send 2 codes for review' })
+    expect(send).toBeDisabled()
+    expect(dialog.getByText('Tick the box to send')).toBeInTheDocument()
+    fireEvent.click(dialog.getByRole('checkbox'))
+    expect(send).toBeEnabled()
+
+    fetchMock.mockImplementationOnce(() =>
+      reply({
+        success: true,
+        data: { id: 'b', counts: { new: 1, change: 1, same: 1, problem: 1 }, imports: [] },
+      })
+    )
+    fireEvent.click(send)
+    await waitFor(() =>
+      expect(toast.success).toHaveBeenCalledWith('Sent. 2 codes wait for review.')
+    )
+    expect(h.track).toHaveBeenCalledWith('fault_code:import_send', {
+      make: 'ford',
+      codes: 2,
+      new: 1,
+      change: 1,
+      source: true,
+    })
+    const [sendUrl, sendInit] = fetchMock.mock.calls[1]
+    expect(sendUrl).toBe('/api/protected/fault-codes/import')
+    const form = sendInit.body as FormData
+    expect(form.get('rights')).toBe('yes')
+    expect(form.get('make')).toBe('ford')
+    expect((form.get('file') as File).name).toBe('ford.json')
+  })
+
+  it('finds the make by typing, and takes one that is not listed as typed', async () => {
+    render(<ImportCodesDialog open onOpenChange={vi.fn()} />)
+    const dialog = within(screen.getByRole('dialog'))
+    fireEvent.click(dialog.getByRole('combobox', { name: 'Make' }))
+    const search = await screen.findByPlaceholderText('Search makes')
+    fireEvent.change(search, { target: { value: 'rover' } })
+    expect(screen.getByRole('option', { name: /Land Rover/ })).toBeInTheDocument()
+    expect(screen.queryByRole('option', { name: /Toyota/ })).not.toBeInTheDocument()
+    // A listed make matches, so the typed letters are not offered as a make of their own.
+    expect(screen.queryByRole('option', { name: 'Use rover' })).not.toBeInTheDocument()
+    fireEvent.change(search, { target: { value: 'for' } })
+    expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['Ford'])
+    // Written with a space instead of the dash, the make is still found.
+    fireEvent.change(search, { target: { value: 'Mercedes Benz' } })
+    expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['Mercedes-Benz'])
+    fireEvent.change(search, { target: { value: 'Ferrari' } })
+    fireEvent.click(screen.getByRole('option', { name: 'Use Ferrari' }))
+    expect(dialog.getByRole('combobox', { name: 'Make' })).toHaveTextContent('Ferrari')
+
+    fetchMock.mockImplementationOnce(() => reply(CHECKED))
+    const csv = new File(['Code,Title\nP1000,Readiness\n'], 'ferrari.csv', { type: 'text/csv' })
+    fireEvent.change(dialog.getByTestId('import-file-input'), { target: { files: [csv] } })
+    await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+    expect((fetchMock.mock.calls[0][1].body as FormData).get('make')).toBe('ferrari')
+  })
+
+  it('says what is wrong with a file and keeps the dialog open when sending is paused', async () => {
+    render(<ImportCodesDialog open onOpenChange={vi.fn()} />)
+    const dialog = within(screen.getByRole('dialog'))
+    fetchMock.mockImplementationOnce(() =>
+      reply({ success: false, error: 'file', problem: 'no_code_column' })
+    )
+    fireEvent.change(dialog.getByTestId('import-file-input'), {
+      target: { files: [new File(['{"make":"Ford","codes":[]}'], 'a.json')] },
+    })
+    await waitFor(() =>
+      expect(
+        dialog.getByText('No Code column was found. Start from the example spreadsheet.')
+      ).toBeInTheDocument()
+    )
+
+    fireEvent.click(dialog.getByRole('button', { name: 'Remove the file' }))
+    fetchMock.mockImplementationOnce(() => reply(CHECKED))
+    fireEvent.change(dialog.getByTestId('import-file-input'), {
+      target: { files: [new File([exampleJson()], 'ford.json')] },
+    })
+    await waitFor(() => expect(dialog.getByText('1 new')).toBeInTheDocument())
+    fireEvent.click(dialog.getByRole('checkbox'))
+    fetchMock.mockImplementationOnce(() => reply({ success: false, error: 'paused' }))
+    fireEvent.click(dialog.getByRole('button', { name: 'Send 2 codes for review' }))
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        'Sending codes is paused right now. Try again later.'
+      )
+    )
+    expect(dialog.getByText('ford.json')).toBeInTheDocument()
+  })
+})

+ 115 - 0
src/__tests__/features/fault-codes/codes.test.ts

@@ -3,9 +3,17 @@ import {
   extractFaultCodes,
   extractFaultCodes,
   faultLine,
   faultLine,
   findingLine,
   findingLine,
+  fixMakesFor,
   isManufacturerCode,
   isManufacturerCode,
   looksLikeFaultCode,
   looksLikeFaultCode,
+  looksLikeOemCode,
+  normalizeAnyMakeCode,
   normalizeFaultCode,
   normalizeFaultCode,
+  normalizeFixCode,
+  normalizeMakeCode,
+  normalizeOemCode,
+  oemSourceFor,
+  vehicleCarriesMake,
 } from '@/features/fault-codes/Lib/codes'
 } from '@/features/fault-codes/Lib/codes'
 import { visiblePageCommands } from '@/features/search/Lib/commands'
 import { visiblePageCommands } from '@/features/search/Lib/commands'
 
 
@@ -130,3 +138,110 @@ describe('how a code finding reads on a print', () => {
     expect(findingLine({ description: 'Worn front pads', faultCode: null })).toBe('Worn front pads')
     expect(findingLine({ description: 'Worn front pads', faultCode: null })).toBe('Worn front pads')
   })
   })
 })
 })
+
+describe('codes a search or a shared fix can name', () => {
+  it("takes a make's code typed in full, never a word or an OBD code", () => {
+    expect(looksLikeOemCode('283700')).toBe(true)
+    expect(looksLikeOemCode('0x2a82')).toBe(true)
+    expect(looksLikeOemCode('2A8')).toBe(false)
+    expect(looksLikeOemCode('P0420')).toBe(false)
+    expect(looksLikeOemCode('lean')).toBe(false)
+  })
+
+  it("keeps a fix for a make's code under its make", () => {
+    expect(normalizeFixCode('p0420')).toBe('P0420')
+    expect(normalizeFixCode('bmw-0x2a82')).toBe('BMW-2A82')
+    expect(normalizeFixCode('2A82')).toBeNull()
+    // Any make a workshop imported codes for, its codes in whatever shape it uses.
+    expect(normalizeFixCode('ford-p1000')).toBe('FORD-P1000')
+    expect(normalizeFixCode('LAND-ROVER-p1000')).toBe('LAND-ROVER-P1000')
+    // BMW's codes stay hex.
+    expect(normalizeFixCode('BMW-P1000')).toBeNull()
+    expect(normalizeFixCode('FORD-')).toBeNull()
+    expect(fixMakesFor('FORD-P1000')).toEqual(['Ford'])
+    expect(fixMakesFor('LAND-ROVER-P1000')).toEqual(['Land Rover'])
+    expect(fixMakesFor('BMW-2A82')).toEqual(['BMW', 'MINI', 'Rolls-Royce'])
+    expect(fixMakesFor('P0420')).toBeNull()
+  })
+
+  it("reads any make's code in one form", () => {
+    expect(normalizeMakeCode('ford', ' p1000 ')).toBe('P1000')
+    expect(normalizeMakeCode('ford', 'B1342-13')).toBe('B1342')
+    expect(normalizeMakeCode('bmw', '0x2a82')).toBe('2A82')
+    expect(normalizeMakeCode('bmw', 'P1000')).toBeNull()
+    expect(normalizeAnyMakeCode('u0100:87')).toBe('U0100')
+    expect(normalizeAnyMakeCode('x')).toBeNull()
+  })
+})
+
+describe('manufacturer codes (BMW)', () => {
+  it('writes them one way and keeps OBD codes apart', () => {
+    expect(normalizeOemCode(' 0x2a82 ')).toBe('2A82')
+    expect(normalizeOemCode('00A0B1')).toBe('00A0B1')
+    expect(normalizeOemCode('P0420')).toBeNull()
+    expect(normalizeOemCode('B1A2F')).toBeNull()
+    expect(normalizeOemCode('VANOS')).toBeNull()
+  })
+
+  it('finds BMW data for BMW, MINI and Rolls-Royce only', () => {
+    expect(oemSourceFor('BMW')).toBe('bmw')
+    expect(oemSourceFor('Mini')).toBe('bmw')
+    expect(oemSourceFor('Rolls-Royce')).toBe('bmw')
+    expect(oemSourceFor('Volkswagen')).toBeNull()
+    expect(oemSourceFor(null)).toBeNull()
+  })
+
+  it('reads BMW codes beside OBD codes only when asked', () => {
+    expect(extractFaultCodes('P0420, 2A82 stored, CD87').map((c) => c.code)).toEqual(['P0420'])
+    expect(extractFaultCodes('P0420, 2A82 stored, CD87', { oem: true })).toEqual([
+      { code: 'P0420' },
+      { code: '2A82', status: 'stored' },
+      { code: 'CD87' },
+    ])
+  })
+
+  it('takes a number without letters for a code only in a list of codes', () => {
+    expect(extractFaultCodes('4110, 2A82', { oem: true }).map((c) => c.code)).toEqual([
+      '4110',
+      '2A82',
+    ])
+    // A year in a sentence is not a code; the code with letters still is.
+    expect(
+      extractFaultCodes('Since 2014 the car shows 2A82 after a cold start', { oem: true }).map(
+        (c) => c.code
+      )
+    ).toEqual(['2A82'])
+  })
+
+  it('reads a word of hex letters as a code only with 0x or in a list of codes', () => {
+    expect(
+      extractFaultCodes('2A82 stored; battery dead after a cold start, face plate loose', {
+        oem: true,
+      }).map((c) => c.code)
+    ).toEqual(['2A82'])
+    expect(extractFaultCodes('Fault 0xDEAD logged', { oem: true }).map((c) => c.code)).toEqual([
+      'DEAD',
+    ])
+    expect(extractFaultCodes('DEAD, 2A82', { oem: true }).map((c) => c.code)).toEqual([
+      'DEAD',
+      '2A82',
+    ])
+  })
+})
+
+describe("which cars carry a make's codes", () => {
+  it("goes by the make, however the car's make is written", () => {
+    expect(vehicleCarriesMake('opel', 'Vauxhall')).toBe(true)
+    expect(vehicleCarriesMake('opel', 'Opel / Vauxhall')).toBe(true)
+    expect(vehicleCarriesMake('skoda', 'Skoda')).toBe(true)
+    expect(vehicleCarriesMake('skoda', 'ŠKODA')).toBe(true)
+    expect(vehicleCarriesMake('citroen', 'Citroen')).toBe(true)
+    expect(vehicleCarriesMake('mercedes-benz', 'Mercedes')).toBe(true)
+    expect(vehicleCarriesMake('volkswagen', 'VW')).toBe(true)
+    // BMW's codes are on MINIs and Rolls-Royces too.
+    expect(vehicleCarriesMake('bmw', 'MINI')).toBe(true)
+    expect(vehicleCarriesMake('bmw', 'Rolls Royce')).toBe(true)
+    expect(vehicleCarriesMake('ford', 'Mazda')).toBe(false)
+    expect(vehicleCarriesMake('ford', null)).toBe(false)
+  })
+})

+ 164 - 0
src/__tests__/features/fault-codes/help-improve.test.tsx

@@ -0,0 +1,164 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * "Help us improve" on the fault code pages: a workshop writes or pastes where a make's codes
+ * are, what is wrong or an idea, and it is mailed to the Torqvoice team with the person to
+ * reply to and the code on screen.
+ */
+
+const h = vi.hoisted(() => ({
+  send: vi.fn(),
+  sendMail: vi.fn(),
+  cloud: true,
+  findOrg: vi.fn(),
+  findUser: vi.fn(),
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
+
+describe('the Help us improve dialog', () => {
+  beforeEach(() => {
+    vi.resetModules()
+    vi.doMock('@/features/fault-codes/Actions/sendFaultCodeFeedback', () => ({
+      sendFaultCodeFeedback: h.send,
+    }))
+    h.send.mockReset()
+    vi.mocked(toast.success).mockReset()
+    vi.mocked(toast.error).mockReset()
+  })
+
+  async function open(code: string | null = 'BMW 2A82') {
+    const { FaultCodesNotice } = await import('@/features/fault-codes/Components/FaultCodesNotice')
+    render(<FaultCodesNotice improve={{ code }} />)
+    fireEvent.click(screen.getByRole('button', { name: 'Help us improve' }))
+    return within(await screen.findByRole('dialog'))
+  }
+
+  it('sends what was written with a topic and the code on screen, then says thanks', async () => {
+    h.send.mockResolvedValueOnce({ success: true, data: { sent: true } })
+    const dialog = await open()
+    expect(dialog.getByText('Help us improve fault codes')).toBeInTheDocument()
+    const send = dialog.getByRole('button', { name: 'Send' })
+    expect(send).toBeDisabled()
+
+    fireEvent.click(dialog.getByRole('button', { name: 'Codes we are missing' }))
+    fireEvent.change(dialog.getByLabelText('Your message'), {
+      target: {
+        value: "Ford's codes: https://www.example.com/ford/dtc and http://parts.example.org/x",
+      },
+    })
+    // The links are picked out, so the sender sees they were understood.
+    expect(dialog.getByRole('list', { name: 'Links in your message' })).toHaveTextContent(
+      'example.com'
+    )
+    expect(dialog.getByText('parts.example.org')).toBeInTheDocument()
+    expect(dialog.getByText('About BMW 2A82')).toBeInTheDocument()
+
+    fireEvent.click(send)
+    await waitFor(() =>
+      expect(toast.success).toHaveBeenCalledWith(
+        'Thanks. Your message is on its way to the Torqvoice team.'
+      )
+    )
+    expect(h.send).toHaveBeenCalledWith({
+      topic: 'makes',
+      message: "Ford's codes: https://www.example.com/ford/dtc and http://parts.example.org/x",
+      code: 'BMW 2A82',
+      page: '/',
+    })
+    await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+  })
+
+  it('leaves the code out when asked, and keeps the message when sending fails', async () => {
+    h.send.mockResolvedValueOnce({ success: true, data: { sent: false, reason: 'limited' } })
+    const dialog = await open()
+    fireEvent.click(dialog.getByRole('button', { name: 'Leave out' }))
+    expect(dialog.queryByText('About BMW 2A82')).not.toBeInTheDocument()
+    fireEvent.change(dialog.getByLabelText('Your message'), { target: { value: 'An idea.' } })
+    fireEvent.click(dialog.getByRole('button', { name: 'Send' }))
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        'You have sent a few messages already. Try again in an hour.'
+      )
+    )
+    expect(h.send.mock.calls[0][0]).toMatchObject({ code: undefined, topic: undefined })
+    expect(dialog.getByLabelText('Your message')).toHaveValue('An idea.')
+  })
+
+  it('is not offered where the workshop is not connected to Torqvoice', async () => {
+    const { FaultCodesNotice } = await import('@/features/fault-codes/Components/FaultCodesNotice')
+    render(<FaultCodesNotice improve={null} />)
+    expect(screen.queryByRole('button', { name: 'Help us improve' })).not.toBeInTheDocument()
+  })
+})
+
+describe('sending it', () => {
+  beforeEach(() => {
+    vi.resetModules()
+    vi.doUnmock('@/features/fault-codes/Actions/sendFaultCodeFeedback')
+    vi.doMock('@/lib/with-auth', () => ({
+      withAuth: async (fn: (ctx: { organizationId: string; userId: string }) => unknown) => {
+        try {
+          return { success: true, data: await fn({ organizationId: 'org1', userId: 'user1' }) }
+        } catch (error) {
+          return { success: false, error: (error as Error).message }
+        }
+      },
+    }))
+    vi.doMock('@/lib/db', () => ({
+      db: { organization: { findUnique: h.findOrg }, user: { findUnique: h.findUser } },
+    }))
+    vi.doMock('@/lib/email', () => ({
+      getFromAddress: async () => 'Torqvoice <noreply@torqvoice.com>',
+      sendMail: h.sendMail,
+    }))
+    vi.doMock('@/lib/support', () => ({ getSupportRecipient: async () => 'post@torqvoice.com' }))
+    vi.doMock('@/lib/features', () => ({ isCloudMode: () => h.cloud }))
+    vi.doMock('@/lib/demo', () => ({ demoGuard: () => undefined }))
+    h.cloud = true
+    h.sendMail.mockReset()
+    h.findOrg.mockResolvedValue({ name: 'Bergen Auto' })
+    h.findUser.mockResolvedValue({ name: 'Kari Nordmann', email: 'kari@bergenauto.no' })
+  })
+
+  it('mails the Torqvoice team, with the person to reply to and the code in the subject', async () => {
+    const { sendFaultCodeFeedback } = await import(
+      '@/features/fault-codes/Actions/sendFaultCodeFeedback'
+    )
+    const result = await sendFaultCodeFeedback({
+      topic: 'source',
+      message: 'https://example.com/bulletin',
+      code: 'BMW 2A82',
+      page: '/fault-codes/oem/bmw/2A82',
+    })
+    expect(result).toEqual({ success: true, data: { sent: true } })
+    const mail = h.sendMail.mock.calls[0][0]
+    expect(mail.to).toBe('post@torqvoice.com')
+    expect(mail.replyTo).toBe('"Kari Nordmann" <kari@bergenauto.no>')
+    expect(mail.subject).toBe('[Improve] Fault codes: A source or link: BMW 2A82')
+    expect(mail.html).toContain('https://example.com/bulletin')
+    expect(mail.html).toContain('Bergen Auto')
+    expect(mail.html).toContain('/fault-codes/oem/bmw/2A82')
+  })
+
+  it('takes five an hour from one person, and none from a self-hosted install', async () => {
+    const { sendFaultCodeFeedback } = await import(
+      '@/features/fault-codes/Actions/sendFaultCodeFeedback'
+    )
+    for (let i = 0; i < 5; i++) {
+      expect(await sendFaultCodeFeedback({ message: `Idea ${i}` })).toMatchObject({
+        data: { sent: true },
+      })
+    }
+    expect(await sendFaultCodeFeedback({ message: 'One more' })).toEqual({
+      success: true,
+      data: { sent: false, reason: 'limited' },
+    })
+    expect(h.sendMail).toHaveBeenCalledTimes(5)
+
+    h.cloud = false
+    const selfHosted = await sendFaultCodeFeedback({ message: 'Hello' })
+    expect(selfHosted.success).toBe(false)
+  })
+})

+ 489 - 2
src/__tests__/features/fault-codes/pages.test.tsx

@@ -21,6 +21,19 @@ const h = vi.hoisted(() => ({
   report: vi.fn(),
   report: vi.fn(),
   phoneLink: vi.fn(),
   phoneLink: vi.fn(),
   draftPhotos: vi.fn(),
   draftPhotos: vi.fn(),
+  research: vi.fn(),
+  rate: vi.fn(),
+  track: vi.fn(),
+}))
+vi.mock('@/lib/analytics', async (original) => ({
+  ...(await original<typeof import('@/lib/analytics')>()),
+  track: h.track,
+}))
+vi.mock('@/features/fault-codes/Actions/researchFaultCode', () => ({
+  researchFaultCode: h.research,
+}))
+vi.mock('@/features/fault-codes/Actions/rateFaultCodeResearch', () => ({
+  rateFaultCodeResearch: h.rate,
 }))
 }))
 
 
 vi.mock('next/navigation', () => ({
 vi.mock('next/navigation', () => ({
@@ -70,6 +83,7 @@ vi.mock('@/features/fault-codes/Actions/reportFaultCodeMeaning', () => ({
 
 
 const { FaultCodeDetail } = await import('@/features/fault-codes/Components/FaultCodeDetail')
 const { FaultCodeDetail } = await import('@/features/fault-codes/Components/FaultCodeDetail')
 const { FaultCodeLookup } = await import('@/features/fault-codes/Components/FaultCodeLookup')
 const { FaultCodeLookup } = await import('@/features/fault-codes/Components/FaultCodeLookup')
+const { FaultCodeOemView } = await import('@/features/fault-codes/Components/FaultCodeOemView')
 const { readRecentLookups, pushRecentLookup } = await import('@/features/fault-codes/Lib/lookups')
 const { readRecentLookups, pushRecentLookup } = await import('@/features/fault-codes/Lib/lookups')
 
 
 function pageData(patch: Partial<FaultCodePageData> = {}): FaultCodePageData {
 function pageData(patch: Partial<FaultCodePageData> = {}): FaultCodePageData {
@@ -249,6 +263,7 @@ const FIXED_IT =
 function ownFix(patch: Partial<OwnFix> = {}): OwnFix {
 function ownFix(patch: Partial<OwnFix> = {}): OwnFix {
   return {
   return {
     id: '0190f3c2-7d1a-7000-8000-000000000001',
     id: '0190f3c2-7d1a-7000-8000-000000000001',
+    meaning: null,
     lang: 'en',
     lang: 'en',
     text: FIXED_IT,
     text: FIXED_IT,
     vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
     vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
@@ -270,6 +285,419 @@ function card(title: string) {
   )
   )
 }
 }
 
 
+const vanos = {
+  id: 'einlass-vanos',
+  title: 'Inlet camshaft adjustment (VANOS)',
+  titleLang: 'en',
+  ownTitle: true,
+  text: 'Einlass-VANOS',
+  textLang: 'de',
+  textEn: null,
+  description: 'Most often a sticking solenoid. Clean or replace the solenoid.',
+  sections: [
+    { kind: 'common_issues' as const, text: 'Most often a sticking solenoid.' },
+    { kind: 'resolution' as const, text: 'Clean or replace the solenoid.' },
+  ],
+  causes: [
+    { key: 'chain', likelihood: 'low' as const, label: 'Stretched timing chain' },
+    { key: 'solenoid', likelihood: 'high' as const, label: 'VANOS solenoid' },
+  ],
+  modules: [
+    { ref: 'msd80', family: 'DME', name: 'MSD80' },
+    { ref: 'msd81', family: 'DME', name: null },
+  ],
+  vehicles: [
+    { chassis: 'E90', model: '3 Series', body: 'Sedan', yearFrom: 2005, yearTo: 2013 },
+    { chassis: 'E92', model: '3 Series', body: 'Coupé', yearFrom: 2006, yearTo: 2013 },
+  ],
+  engines: ['N54'],
+  matches: true,
+}
+const lambda = {
+  ...vanos,
+  id: 'lambdasonden-nach-katalysator-vertauscht',
+  title: 'Lambdasonden nach Katalysator, vertauscht',
+  titleLang: 'de',
+  ownTitle: false,
+  text: 'Lambdasonden nach Katalysator, vertauscht',
+  description: null,
+  sections: [],
+  causes: [],
+  modules: [{ ref: 'mev17n46', family: 'DME', name: null }],
+  vehicles: [{ chassis: 'F30', model: '3 Series', body: 'Sedan', yearFrom: 2012, yearTo: null }],
+  engines: ['N46'],
+  matches: false,
+}
+
+function bmwPage(meanings = [vanos, lambda]) {
+  return {
+    ...pageData({ code: '2A82', report: null, oemMakes: ['bmw'] }),
+    oem: { make: 'bmw', makeName: 'BMW', fixCode: 'BMW-2A82', served: true, meanings },
+  }
+}
+
+describe("a BMW code's own page", () => {
+  it("reads like every code page: our title, BMW's words, the parts and where BMW uses it", () => {
+    renderPage(<FaultCodeOemView data={bmwPage()} layout="page" />)
+    expect(
+      screen.getByRole('heading', { level: 1, name: 'Inlet camshaft adjustment (VANOS)' })
+    ).toBeInTheDocument()
+    expect(screen.getByText('BMW code')).toBeInTheDocument()
+    expect(screen.getByText("BMW's text: Einlass-VANOS")).toBeInTheDocument()
+    // The OBD page's cards, the description under its headings.
+    expect(screen.getByRole('heading', { name: 'What it means' })).toBeInTheDocument()
+    expect(screen.getByText('Common issues')).toBeInTheDocument()
+    expect(screen.getByText('How to fix it')).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'Likely causes' })).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'Where BMW uses it' })).toBeInTheDocument()
+    expect(screen.getByText('E90, E92')).toBeInTheDocument()
+    expect(screen.getByText('2005 to 2013')).toBeInTheDocument()
+    expect(screen.getByText('DME · msd80')).toBeInTheDocument()
+    // Shared fixes and the jobs here, as on any code.
+    expect(screen.getByRole('heading', { name: 'Share your fix' })).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'In your workshop' })).toBeInTheDocument()
+    // No "report this meaning": BMW's own data has nothing of ours to correct.
+    expect(screen.queryByRole('button', { name: /Report/ })).not.toBeInTheDocument()
+    // The notice says BMW's codes are covered.
+    expect(screen.getByText(/and BMW's own codes/)).toBeInTheDocument()
+    expect(readRecentLookups()[0]).toMatchObject({ kind: 'oem', make: 'bmw', code: '2A82' })
+  })
+
+  it('switches between the meanings a code has in different control units', () => {
+    renderPage(<FaultCodeOemView data={bmwPage()} layout="page" />)
+    expect(screen.getByText(/2 meanings/)).toBeInTheDocument()
+    fireEvent.click(screen.getByRole('button', { name: /Lambdasonden nach Katalysator/ }))
+    expect(
+      screen.getByRole('heading', { level: 1, name: 'Lambdasonden nach Katalysator, vertauscht' })
+    ).toBeInTheDocument()
+    expect(screen.queryByRole('heading', { name: 'Likely causes' })).not.toBeInTheDocument()
+    expect(screen.getByText('F30')).toBeInTheDocument()
+    expect(screen.getByText('from 2012')).toBeInTheDocument()
+  })
+
+  it('shares a fix for the meaning on screen, the make already BMW, and lists fixes per meaning', async () => {
+    const lambdaFix = {
+      ...ownFix({
+        id: '0190f3c2-7d1a-7000-8000-00000000000a',
+        text: 'Swapped the lambda plugs back.',
+      }),
+      meaning: 'lambdasonden-nach-katalysator-vertauscht',
+    }
+    const data = bmwPage()
+    data.fixes = { available: true, published: [], own: [lambdaFix] }
+    renderPage(<FaultCodeOemView data={data} layout="page" />)
+    // The VANOS meaning is on screen: the lambda fix is not its.
+    expect(screen.queryByText('Swapped the lambda plugs back.')).not.toBeInTheDocument()
+    expect(screen.getByText(/Tell other workshops what fixed BMW 2A82/)).toBeInTheDocument()
+
+    fireEvent.click(screen.getByRole('button', { name: /Share your fix/ }))
+    const dialog = await screen.findByRole('dialog')
+    expect(within(dialog).getByText('Share your fix for BMW 2A82')).toBeInTheDocument()
+    expect(within(dialog).getByText('Inlet camshaft adjustment (VANOS)')).toBeInTheDocument()
+    expect(within(dialog).getByRole('combobox', { name: 'Make' })).toHaveTextContent('BMW')
+
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ success: true, data: ownFix({ meaning: 'einlass-vanos' }) }))
+    )
+    fireEvent.change(within(dialog).getByLabelText('What fixed it'), {
+      target: { value: FIXED_IT },
+    })
+    fireEvent.click(within(dialog).getByRole('button', { name: 'Share fix' }))
+    await waitFor(() => expect(fetchMock).toHaveBeenCalled())
+    const sent = fetchMock.mock.calls[0][1].body as FormData
+    expect(sent.get('code')).toBe('BMW-2A82')
+    expect(sent.get('meaning')).toBe('einlass-vanos')
+    expect(sent.get('make')).toBe('BMW')
+
+    // On the lambda meaning, the lambda fix shows.
+    fireEvent.click(screen.getByRole('button', { name: /Lambdasonden nach Katalysator/ }))
+    expect(screen.getByText('Swapped the lambda plugs back.')).toBeInTheDocument()
+  })
+
+  it('says so when BMW codes are not served, or BMW has no such code', () => {
+    const off = bmwPage([])
+    off.oem.served = false
+    const { unmount } = renderPage(<FaultCodeOemView data={off} layout="page" />)
+    expect(screen.getByText('BMW codes are not available yet.')).toBeInTheDocument()
+    unmount()
+    renderPage(<FaultCodeOemView data={bmwPage([])} layout="page" />)
+    expect(screen.getByText("2A82 is not in BMW's data.")).toBeInTheDocument()
+  })
+})
+
+const RESEARCHED = {
+  id: '0190f3c2-7d1a-7000-8000-0000000000aa',
+  code: 'P0420',
+  meaning: null,
+  status: 'pending' as const,
+  findings: {
+    title: 'Catalyst efficiency below threshold, bank 1',
+    meaning: 'The rear oxygen sensor shows the catalyst no longer stores enough oxygen.',
+    commonIssues: ['Exhaust leak before the rear sensor'],
+    steps: [
+      'Check for exhaust leaks between the front pipe and the rear sensor.',
+      'Compare front and rear sensor voltages at 2500 rpm.',
+    ],
+    causes: [
+      { label: 'Rear sensor slow', likelihood: 'low' as const },
+      { label: 'Worn catalyst', likelihood: 'high' as const },
+    ],
+    advice: 'Fix misfires first.',
+    sources: [
+      { url: 'https://www.obd-codes.com/p0420', title: 'P0420' },
+      { url: 'https://forum.example.org/t/1', title: null },
+    ],
+  },
+  reason: null,
+  requests: 1,
+  helpful: 0,
+  notHelpful: 0,
+  createdAt: '2026-11-06T10:00:00Z',
+}
+
+describe('a make known only from what workshops imported', () => {
+  const readiness = {
+    ...vanos,
+    id: '',
+    title: 'OBD readiness tests not complete',
+    titleLang: 'en',
+    ownTitle: true,
+    text: '',
+    textLang: '',
+    description: 'The self tests have not run.\n\nDrive a full drive cycle.',
+    sections: [
+      { kind: 'meaning' as const, text: 'The self tests have not run.' },
+      { kind: 'resolution' as const, text: 'Drive a full drive cycle.' },
+    ],
+    modules: [],
+    vehicles: [],
+    engines: [],
+    matches: false,
+  }
+  const FOCUS = { id: 'v2', make: 'Ford', model: 'Focus', year: 2015, engine: null }
+
+  it("reads like any make's code, without control units or the make's own words", () => {
+    renderPage(
+      <FaultCodeOemView
+        data={{
+          ...pageData({ code: 'P1000', report: null, oemMakes: ['bmw', 'ford'], vehicle: FOCUS }),
+          oem: {
+            make: 'ford',
+            makeName: 'Ford',
+            fixCode: 'FORD-P1000',
+            served: true,
+            meanings: [readiness],
+          },
+        }}
+        layout="page"
+      />
+    )
+    expect(
+      screen.getByRole('heading', { level: 1, name: 'OBD readiness tests not complete' })
+    ).toBeInTheDocument()
+    expect(screen.getByText('Ford code')).toBeInTheDocument()
+    expect(screen.getByText('Drive a full drive cycle.')).toBeInTheDocument()
+    expect(screen.queryByText(/Ford's text/)).not.toBeInTheDocument()
+    expect(screen.queryByRole('heading', { name: 'Where Ford uses it' })).not.toBeInTheDocument()
+    // No units to match the car against, so no warning that none matches.
+    expect(screen.queryByText(/None of these meanings/)).not.toBeInTheDocument()
+    expect(screen.getByText(/own codes of BMW and Ford/)).toBeInTheDocument()
+  })
+
+  it('counts each code opened, and whether we had a meaning for it', () => {
+    h.track.mockReset()
+    const { unmount } = renderPage(<FaultCodeDetail data={pageData()} layout="page" />)
+    expect(h.track).toHaveBeenCalledWith('fault_code:page_view', {
+      code: 'P0420',
+      kind: 'obd',
+      make: null,
+      found: true,
+      layout: 'page',
+      on_job: false,
+    })
+    unmount()
+    h.track.mockReset()
+    const missing = pageData({ code: 'P1999' })
+    missing.detail = { ...missing.detail, code: 'P1999', title: null, state: 'missing' as never }
+    renderPage(<FaultCodeDetail data={missing} layout="sheet" />)
+    expect(h.track).toHaveBeenCalledWith(
+      'fault_code:page_view',
+      expect.objectContaining({ code: 'P1999', found: false, layout: 'sheet' })
+    )
+  })
+
+  it("shows a make's own meaning on the standard code's page, the car's make first", () => {
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({
+          code: 'P1000',
+          vehicle: FOCUS,
+          makeCodes: [
+            {
+              make: 'ford',
+              makeName: 'Ford',
+              code: 'P1000',
+              title: 'OBD readiness tests not complete',
+              meanings: 1,
+            },
+            {
+              make: 'mazda',
+              makeName: 'Mazda',
+              code: 'P1000',
+              title: 'OBD-II monitor testing not complete',
+              meanings: 1,
+            },
+          ],
+        })}
+        layout="page"
+      />
+    )
+    expect(screen.getByText('Makes with their own meaning for P1000')).toBeInTheDocument()
+    const ford = screen.getByRole('link', { name: /Ford.*OBD readiness tests not complete/ })
+    expect(ford).toHaveAttribute('href', '/fault-codes/oem/ford/P1000')
+    expect(within(ford).getByText("This car's make")).toBeInTheDocument()
+    h.track.mockReset()
+    fireEvent.click(ford)
+    expect(h.track).toHaveBeenCalledWith('fault_code:make_code_open', {
+      code: 'P1000',
+      make: 'ford',
+      own_make: true,
+    })
+    expect(screen.getByRole('link', { name: /Mazda/ })).toHaveAttribute(
+      'href',
+      '/fault-codes/oem/mazda/P1000'
+    )
+  })
+})
+
+describe('researching a fix online', () => {
+  it('offers to research when there is none, then shows what was found for a person to check', async () => {
+    h.research.mockResolvedValueOnce({
+      success: true,
+      data: { outcome: 'done', research: RESEARCHED },
+    })
+    h.rate.mockResolvedValueOnce({ success: true, data: { rated: true } })
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({ research: { available: true, items: [] } })}
+        layout="page"
+      />
+    )
+    expect(screen.getByRole('heading', { name: 'Research a fix online' })).toBeInTheDocument()
+    fireEvent.click(screen.getByRole('button', { name: 'Research a fix' }))
+    expect(screen.getByText('Researching P0420')).toBeInTheDocument()
+    expect(h.research).toHaveBeenCalledWith({ code: 'P0420', meaning: undefined, vehicleId: null })
+
+    await waitFor(() =>
+      expect(screen.getByText('Catalyst efficiency below threshold, bank 1')).toBeInTheDocument()
+    )
+    expect(screen.getByText('Waiting for a person to check it')).toBeInTheDocument()
+    expect(screen.getByText(/Check it against the car/)).toBeInTheDocument()
+    expect(
+      screen.getByText('Compare front and rear sensor voltages at 2500 rpm.')
+    ).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: /obd-codes.com/ })).toHaveAttribute(
+      'href',
+      'https://www.obd-codes.com/p0420'
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Yes, it fixed it' }))
+    await waitFor(() => expect(screen.getByText('Thanks, noted')).toBeInTheDocument())
+    expect(h.rate).toHaveBeenCalledWith({ id: RESEARCHED.id, helpful: true })
+  })
+
+  it('shows the finished research when the page is read again', () => {
+    const running = { ...RESEARCHED, status: 'running' as const, findings: null }
+    const { rerender } = renderPage(
+      <FaultCodeDetail
+        data={pageData({ research: { available: true, items: [running] } })}
+        layout="page"
+      />
+    )
+    expect(screen.getByText('Being researched right now')).toBeInTheDocument()
+    // "Check again" reads the page again; the card takes what it brings.
+    rerender(
+      <ConfirmProvider>
+        <TooltipProvider>
+          <FaultCodeDetail
+            data={pageData({ research: { available: true, items: [RESEARCHED] } })}
+            layout="page"
+          />
+        </TooltipProvider>
+      </ConfirmProvider>
+    )
+    expect(screen.getByText('Catalyst efficiency below threshold, bank 1')).toBeInTheDocument()
+    expect(screen.queryByText('Being researched right now')).not.toBeInTheDocument()
+  })
+
+  it('says plainly when nothing reliable was found, and is not there when research is off', () => {
+    const missed = { ...RESEARCHED, status: 'missed' as const, findings: null }
+    const { unmount } = renderPage(
+      <FaultCodeDetail
+        data={pageData({ research: { available: true, items: [missed] } })}
+        layout="page"
+      />
+    )
+    expect(screen.getByText('No reliable fix found online')).toBeInTheDocument()
+    expect(screen.getByText(/so nothing was saved/)).toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: 'Research a fix' })).not.toBeInTheDocument()
+    unmount()
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({ research: { available: false, items: [] } })}
+        layout="page"
+      />
+    )
+    expect(screen.queryByText('Research a fix online')).not.toBeInTheDocument()
+  })
+
+  it('shows a checked research to every workshop, and a busy one as such', () => {
+    const checked = { ...RESEARCHED, status: 'published' as const }
+    const { unmount } = renderPage(
+      <FaultCodeDetail
+        data={pageData({ research: { available: false, items: [checked] } })}
+        layout="page"
+      />
+    )
+    expect(screen.getByText('Checked by Torqvoice')).toBeInTheDocument()
+    expect(screen.queryByText(/Check it against the car/)).not.toBeInTheDocument()
+    unmount()
+    h.research.mockResolvedValueOnce({ success: true, data: { outcome: 'busy' } })
+    renderPage(
+      <FaultCodeDetail
+        data={pageData({ research: { available: true, items: [] } })}
+        layout="page"
+      />
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Research a fix' }))
+    return waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        'This code is being researched right now. Check again in a minute.'
+      )
+    )
+  })
+
+  it("researches a BMW code's meaning on screen, each meaning its own", () => {
+    const data = bmwPage()
+    data.research = {
+      available: true,
+      items: [
+        {
+          ...RESEARCHED,
+          code: 'BMW-2A82',
+          meaning: 'lambdasonden-nach-katalysator-vertauscht',
+          findings: { ...RESEARCHED.findings, title: 'Lambda sensor plugs swapped' },
+        },
+      ],
+    }
+    renderPage(<FaultCodeOemView data={data} layout="page" />)
+    // The VANOS meaning has no research yet: it can be asked for.
+    expect(screen.getByText(/BMW 2A82 · Inlet camshaft adjustment/)).toBeInTheDocument()
+    expect(screen.queryByText('Lambda sensor plugs swapped')).not.toBeInTheDocument()
+    fireEvent.click(screen.getByRole('button', { name: /Lambdasonden nach Katalysator/ }))
+    expect(screen.getByText('Lambda sensor plugs swapped')).toBeInTheDocument()
+  })
+})
+
 describe('sharing what fixed a code', () => {
 describe('sharing what fixed a code', () => {
   /** Opens the share dialog from the card's invitation. */
   /** Opens the share dialog from the card's invitation. */
   async function openShare() {
   async function openShare() {
@@ -362,7 +790,7 @@ describe('sharing what fixed a code', () => {
     await waitFor(() => expect(dialog.getByText('1 photo from your phone')).toBeInTheDocument())
     await waitFor(() => expect(dialog.getByText('1 photo from your phone')).toBeInTheDocument())
     expect(dialog.getByText('Phone')).toBeInTheDocument()
     expect(dialog.getByText('Phone')).toBeInTheDocument()
     const draftId = h.phoneLink.mock.calls[0][0].draftId as string
     const draftId = h.phoneLink.mock.calls[0][0].draftId as string
-    expect(h.phoneLink).toHaveBeenCalledWith({ code: 'P0420', draftId })
+    expect(h.phoneLink).toHaveBeenCalledWith({ code: 'P0420', draftId, purpose: 'fix' })
 
 
     fireEvent.change(dialog.getByLabelText('What fixed it'), { target: { value: FIXED_IT } })
     fireEvent.change(dialog.getByLabelText('What fixed it'), { target: { value: FIXED_IT } })
     fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
     fireEvent.click(dialog.getByRole('button', { name: 'Share fix' }))
@@ -429,6 +857,7 @@ describe('sharing what fixed a code', () => {
   it("lists other workshops' fixes with the vehicle and photos, never the workshop", () => {
   it("lists other workshops' fixes with the vehicle and photos, never the workshop", () => {
     const theirs: SharedFix = {
     const theirs: SharedFix = {
       id: '0190f3c2-7d1a-7000-8000-000000000002',
       id: '0190f3c2-7d1a-7000-8000-000000000002',
+      meaning: null,
       lang: 'en',
       lang: 'en',
       text: 'Replaced the rear O2 sensor; the plug was fine.',
       text: 'Replaced the rear O2 sensor; the plug was fine.',
       vehicle: { make: 'Skoda', model: 'Octavia', year: 2016, engine: null },
       vehicle: { make: 'Skoda', model: 'Octavia', year: 2016, engine: null },
@@ -478,6 +907,8 @@ const overview = {
   query: '',
   query: '',
   available: true,
   available: true,
   hits: [],
   hits: [],
+  oemHits: [],
+  oemMakes: [],
   recent: [],
   recent: [],
 }
 }
 
 
@@ -508,6 +939,7 @@ describe('the lookup page', () => {
           query: 'catalyst',
           query: 'catalyst',
           available: true,
           available: true,
           hits: [{ code: 'P0420', title: 'Catalyst System Efficiency Below Threshold Bank 1' }],
           hits: [{ code: 'P0420', title: 'Catalyst System Efficiency Below Threshold Bank 1' }],
+          oemHits: [],
         },
         },
       })
       })
       renderPage(<FaultCodeLookup overview={overview} />)
       renderPage(<FaultCodeLookup overview={overview} />)
@@ -537,6 +969,61 @@ describe('the lookup page', () => {
     expect(h.push).toHaveBeenLastCalledWith('/fault-codes?q=lean%20bank%201')
     expect(h.push).toHaveBeenLastCalledWith('/fault-codes?q=lean%20bank%201')
     expect(readRecentLookups()[0]).toMatchObject({ kind: 'words', query: 'lean bank 1' })
     expect(readRecentLookups()[0]).toMatchObject({ kind: 'words', query: 'lean bank 1' })
   })
   })
+
+  it('shows a BMW code as a result of its own, and opens it on Enter', () => {
+    const bmw = {
+      ...overview,
+      query: '283700',
+      oemMakes: ['bmw'],
+      oemHits: [
+        {
+          make: 'bmw',
+          makeName: 'BMW',
+          code: '283700',
+          title: 'Oxygen sensor, heater: temperature too low',
+          meanings: 2,
+        },
+      ],
+    }
+    renderPage(<FaultCodeLookup overview={bmw} />)
+    expect(screen.getByText('Codes matching “283700”')).toBeInTheDocument()
+    const row = screen.getByRole('link', { name: /283700.*BMW code.*Oxygen sensor/ })
+    expect(row).toHaveAttribute('href', '/fault-codes/oem/bmw/283700')
+    expect(row).toHaveTextContent('and 1 other meaning')
+    expect(screen.queryByText(/No code matches/)).not.toBeInTheDocument()
+    // The notice says BMW's codes are in.
+    expect(screen.getByText(/and BMW's own codes/)).toBeInTheDocument()
+
+    const box = screen.getByLabelText('Code or words')
+    fireEvent.submit(box.closest('form') as HTMLFormElement)
+    expect(h.push).toHaveBeenLastCalledWith('/fault-codes/oem/bmw/283700')
+  })
+
+  it('lists the makes, rather than guessing one, when two makes have the code', () => {
+    const shared = {
+      ...overview,
+      query: '4A10',
+      oemMakes: ['bmw', 'ford'],
+      oemHits: [
+        { make: 'bmw', makeName: 'BMW', code: '4A10', title: 'Fuel pump', meanings: 1 },
+        { make: 'ford', makeName: 'Ford', code: '4A10', title: 'Body module', meanings: 1 },
+      ],
+    }
+    renderPage(<FaultCodeLookup overview={shared} />)
+    const box = screen.getByLabelText('Code or words')
+    fireEvent.submit(box.closest('form') as HTMLFormElement)
+    expect(h.push).toHaveBeenLastCalledWith('/fault-codes?q=4A10')
+  })
+
+  it('searches words for a BMW-looking number when BMW codes are not served', () => {
+    renderPage(<FaultCodeLookup overview={overview} />)
+    const box = screen.getByLabelText('Code or words')
+    fireEvent.change(box, { target: { value: '283700' } })
+    fireEvent.submit(box.closest('form') as HTMLFormElement)
+    expect(h.push).toHaveBeenLastCalledWith('/fault-codes?q=283700')
+    // Without BMW served, the notice says makes' own codes are still to come.
+    expect(screen.getByText(/working on adding makes' own codes/)).toBeInTheDocument()
+  })
 })
 })
 
 
 describe('recent lookups', () => {
 describe('recent lookups', () => {
@@ -544,7 +1031,7 @@ describe('recent lookups', () => {
     for (let i = 0; i < 12; i++)
     for (let i = 0; i < 12; i++)
       pushRecentLookup({ kind: 'code', code: `P01${10 + i}`, title: null })
       pushRecentLookup({ kind: 'code', code: `P01${10 + i}`, title: null })
     pushRecentLookup({ kind: 'code', code: 'P0115', title: null })
     pushRecentLookup({ kind: 'code', code: 'P0115', title: null })
-    const codes = readRecentLookups().map((r) => (r.kind === 'code' ? r.code : r.query))
+    const codes = readRecentLookups().map((r) => (r.kind === 'words' ? r.query : r.code))
     expect(codes).toHaveLength(10)
     expect(codes).toHaveLength(10)
     expect(codes[0]).toBe('P0115')
     expect(codes[0]).toBe('P0115')
     expect(codes.filter((c) => c === 'P0115')).toHaveLength(1)
     expect(codes.filter((c) => c === 'P0115')).toHaveLength(1)

+ 119 - 3
src/__tests__/features/fault-codes/service.test.ts

@@ -10,6 +10,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
 const h = vi.hoisted(() => ({
 const h = vi.hoisted(() => ({
   connections: [] as { id: string; connectorId: string }[],
   connections: [] as { id: string; connectorId: string }[],
   lookupCodes: vi.fn(),
   lookupCodes: vi.fn(),
+  oemCode: vi.fn(),
+  oemMakes: vi.fn(async () => ['bmw']),
   shareFix: vi.fn(),
   shareFix: vi.fn(),
   fixStatus: vi.fn(),
   fixStatus: vi.fn(),
   withdrawFix: vi.fn(),
   withdrawFix: vi.fn(),
@@ -63,6 +65,8 @@ vi.mock('@/features/integrations/Lib/connections', () => ({
     ctx: { links },
     ctx: { links },
     server: {
     server: {
       lookupCodes: h.lookupCodes,
       lookupCodes: h.lookupCodes,
+      oemCode: h.oemCode,
+      oemMakes: h.oemMakes,
       shareFix: h.shareFix,
       shareFix: h.shareFix,
       fixStatus: h.fixStatus,
       fixStatus: h.fixStatus,
       withdrawFix: h.withdrawFix,
       withdrawFix: h.withdrawFix,
@@ -71,9 +75,8 @@ vi.mock('@/features/integrations/Lib/connections', () => ({
   })),
   })),
 }))
 }))
 
 
-const { fixesFor, lookupMeanings, shareFix, viewOf, withdrawSharedFix } = await import(
-  '@/features/fault-codes/Lib/service.server'
-)
+const { fixesFor, lookupMeanings, searchOemCodes, shareFix, viewOf, withdrawSharedFix } =
+  await import('@/features/fault-codes/Lib/service.server')
 
 
 const P0420 = {
 const P0420 = {
   code: 'P0420',
   code: 'P0420',
@@ -212,6 +215,119 @@ const fix = (id: string, patch: Record<string, unknown> = {}) => ({
   ...patch,
   ...patch,
 })
 })
 
 
+describe('BMW codes on a BMW', () => {
+  const VANOS = {
+    id: 'einlass-vanos',
+    text: 'Einlass-VANOS',
+    textLang: 'de',
+    textEn: 'Inlet VANOS',
+    modules: [{ ref: 'msd80', family: 'DME', name: null }],
+    vehicles: [],
+    engines: ['N54'],
+    matches: true,
+    lang: null,
+    title: null,
+    description: null,
+    sections: [],
+    causes: [],
+  }
+
+  it('looks a BMW code up in BMW data with the engine, in English where BMW has it', async () => {
+    h.connections = [{ id: 'c1', connectorId: 'torqvoice-codes' }]
+    h.oemCode.mockReset()
+    h.lookupCodes.mockReset()
+    h.oemCode.mockResolvedValueOnce({ source: 'bmw', code: '2A82', meanings: [VANOS] })
+    h.lookupCodes.mockResolvedValueOnce([P0420])
+    const { available, results } = await lookupMeanings(
+      'org1',
+      ['P0420', '2A82'],
+      { make: 'BMW', model: '335i', year: 2008, engine: 'N54B30' },
+      'en'
+    )
+    expect(available).toBe(true)
+    expect(h.oemCode).toHaveBeenCalledWith(expect.anything(), {
+      source: 'bmw',
+      code: '2A82',
+      engine: 'N54B30',
+      locale: 'en',
+    })
+    expect(h.lookupCodes.mock.calls[0][1].codes).toEqual(['P0420'])
+    expect(results[1]).toMatchObject({
+      code: '2A82',
+      state: 'found',
+      title: 'Inlet VANOS',
+      lang: 'en',
+      source: 'manufacturer',
+      range: 'manufacturer',
+    })
+  })
+
+  it("shows BMW's own text where there is no English, and nothing for a code BMW lacks", async () => {
+    h.connections = [{ id: 'c1', connectorId: 'torqvoice-codes' }]
+    h.oemCode.mockReset()
+    h.oemCode
+      .mockResolvedValueOnce({ source: 'bmw', code: '2A82', meanings: [VANOS] })
+      .mockResolvedValueOnce({ source: 'bmw', code: 'CD87', meanings: [] })
+    const { results } = await lookupMeanings(
+      'org1',
+      ['2A82', 'CD87'],
+      { make: 'MINI', model: 'Cooper', year: 2010 },
+      'nb'
+    )
+    expect(results[0]).toMatchObject({ title: 'Einlass-VANOS', lang: 'de' })
+    expect(results[1]).toMatchObject({ code: 'CD87', state: 'unknown', title: null })
+  })
+
+  it("finds a make's code typed in full in each make the service serves", async () => {
+    h.connections = [{ id: 'c1', connectorId: 'torqvoice-codes' }]
+    h.oemCode.mockReset()
+    h.oemCode.mockResolvedValueOnce({
+      source: 'bmw',
+      code: '2A82',
+      meanings: [
+        { ...VANOS, lang: 'en', title: 'Inlet camshaft adjustment (VANOS)' },
+        { ...VANOS, id: 'other', text: 'Lambdasonden' },
+      ],
+    })
+    const hits = await searchOemCodes('org1', '0x2a82', 'en')
+    expect(h.oemCode).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.objectContaining({ source: 'bmw', code: '2A82' })
+    )
+    expect(hits).toEqual([
+      {
+        make: 'bmw',
+        makeName: 'BMW',
+        code: '2A82',
+        title: 'Inlet camshaft adjustment (VANOS)',
+        meanings: 2,
+      },
+    ])
+    // Too short to be a whole code, or not one: nothing asked.
+    h.oemCode.mockReset()
+    expect(await searchOemCodes('org1', '2A8', 'en')).toEqual([])
+    expect(await searchOemCodes('org1', 'lean bank', 'en')).toEqual([])
+    expect(h.oemCode).not.toHaveBeenCalled()
+  })
+
+  it('names the code by our own title when we have written text for it', async () => {
+    h.connections = [{ id: 'c1', connectorId: 'torqvoice-codes' }]
+    h.oemCode.mockReset()
+    h.oemCode.mockResolvedValueOnce({
+      source: 'bmw',
+      code: '2A82',
+      meanings: [{ ...VANOS, lang: 'en', title: 'Inlet camshaft adjustment (VANOS)' }],
+    })
+    const { results } = await lookupMeanings(
+      'org1',
+      ['2A82'],
+      { make: 'BMW', model: '335i', year: 2008 },
+      'nb'
+    )
+    expect(results[0]).toMatchObject({ title: 'Inlet camshaft adjustment (VANOS)', lang: 'en' })
+  })
+})
+
 describe('shared fixes for a workshop', () => {
 describe('shared fixes for a workshop', () => {
   it('remembers only the id of a fix it shared, and shows it waiting for review', async () => {
   it('remembers only the id of a fix it shared, and shows it waiting for review', async () => {
     h.shareFix.mockResolvedValueOnce({ ok: true, id: 'f1' })
     h.shareFix.mockResolvedValueOnce({ ok: true, id: 'f1' })

+ 52 - 0
src/__tests__/features/fault-codes/waveform-csv.test.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { PREVIEW_SLICES, previewCsv, waveformRole } from '@/features/fault-codes/Lib/waveformCsv'
+
+/**
+ * A scope's CSV export drawn in the browser the moment it is dropped. The same reading as the
+ * code service's, so what the technician sees before sharing is what other workshops see.
+ */
+describe('reading a CSV export', () => {
+  it('draws a PicoScope export with its names, units and edges', () => {
+    let csv = 'Time,Channel A,Channel B\r\n(ms),(V),(V)\r\n\r\n'
+    for (let i = 0; i < 2000; i++) {
+      const a = Math.floor(i / 100) % 2 === 0 ? 0 : 5
+      csv += `${(i * 0.01).toFixed(5)},${a.toFixed(3)},${Math.sin(i / 300).toFixed(3)}\n`
+    }
+    const p = previewCsv(csv)
+    expect(p?.timeUnit).toBe('ms')
+    expect(p?.channels.map((c) => [c.name, c.unit])).toEqual([
+      ['Channel A', 'V'],
+      ['Channel B', 'V'],
+    ])
+    expect(p?.channels[0].min).toHaveLength(PREVIEW_SLICES)
+    // The square wave's lows and highs both survive the slicing.
+    const highs = p?.channels[0].max.filter((v) => v !== null && v > 4.9).length ?? 0
+    const lows = p?.channels[0].min.filter((v) => v !== null && v < 0.1).length ?? 0
+    expect(highs).toBeGreaterThan(100)
+    expect(lows).toBeGreaterThan(100)
+  })
+
+  it('reads a European export with decimal commas, past-range values left out', () => {
+    const p = previewCsv('Zeit;Kanal A\n(ms);(V)\n\n0,0;1,5\n0,5;∞\n1,0;2,5\n')
+    expect(p?.channels[0].name).toBe('Kanal A')
+    expect(p?.end).toBe(1)
+    expect(p?.channels[0].max.filter((v) => v !== null)).toEqual([1.5, 2.5])
+  })
+
+  it('has nothing to draw for a file that is not an export', () => {
+    expect(previewCsv('hello')).toBeNull()
+    expect(previewCsv('Time,A\n(ms),(V)\n1,2\n')).toBeNull()
+    expect(previewCsv('name,price\nbolt,2\n')).toBeNull()
+  })
+})
+
+describe('sorting a dropped file', () => {
+  it('knows each file by its name, whatever the case', () => {
+    expect(waveformRole('N54 cam crank.PSDATA', '')).toBe('psdata')
+    expect(waveformRole('cam crank.pssettings', '')).toBe('pssettings')
+    expect(waveformRole('export.csv', 'text/csv')).toBe('csv')
+    expect(waveformRole('IMG_2041.HEIC', '')).toBe('image')
+    expect(waveformRole('screen', 'image/png')).toBe('image')
+    expect(waveformRole('notes.docx', '')).toBeNull()
+  })
+})

+ 387 - 0
src/__tests__/features/fault-codes/waveforms.test.tsx

@@ -0,0 +1,387 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { toast } from 'sonner'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider } from '@/components/confirm-dialog'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { CodeWaveforms, FaultCodePageData } from '@/features/fault-codes/Lib/types'
+import { previewCsv } from '@/features/fault-codes/Lib/waveformCsv'
+import type {
+  FaultCodeOwnWaveform,
+  FaultCodeWaveform,
+  WaveformPreview,
+} from '@/features/integrations/Lib/types'
+
+/**
+ * Waveforms on a code's page as a technician uses them: other workshops' scope recordings,
+ * known good and faulty, opened full size beside a known good one with their files to
+ * download, and sharing one from the bench with whatever files are at hand.
+ */
+
+const h = vi.hoisted(() => ({
+  withdraw: vi.fn(),
+  phoneLink: vi.fn(),
+  draftPhotos: vi.fn(),
+}))
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) }))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
+vi.mock('@/features/support/Lib/supportVisibility', () => ({ openSupport: vi.fn() }))
+vi.mock('@/features/fault-codes/Actions/researchFaultCode', () => ({ researchFaultCode: vi.fn() }))
+vi.mock('@/features/fault-codes/Actions/rateFaultCodeResearch', () => ({
+  rateFaultCodeResearch: vi.fn(),
+}))
+vi.mock('@/features/fault-codes/Actions/withdrawFaultCodeFix', () => ({
+  withdrawFaultCodeFix: vi.fn(),
+}))
+vi.mock('@/features/fault-codes/Actions/withdrawFaultCodeWaveform', () => ({
+  withdrawFaultCodeWaveform: h.withdraw,
+}))
+vi.mock('@/features/fault-codes/Actions/reportFaultCodeMeaning', () => ({
+  reportFaultCodeMeaning: vi.fn(),
+}))
+vi.mock('@/features/fault-codes/Actions/createFixPhoneLink', () => ({
+  createFixPhoneLink: h.phoneLink,
+}))
+vi.mock('@/features/fault-codes/Actions/listFixDraftPhotos', () => ({
+  listFixDraftPhotos: h.draftPhotos,
+}))
+vi.mock('@/features/fault-codes/Actions/removeFixDraftPhoto', () => ({
+  removeFixDraftPhoto: vi.fn(async () => ({ success: true })),
+}))
+vi.mock('@/features/vehicles/Components/service-edit/RichTextEditor', () => ({
+  RichTextEditor: () => null,
+}))
+
+const { FaultCodeDetail } = await import('@/features/fault-codes/Components/FaultCodeDetail')
+
+/** A browser upload that answers with `reply`, keeping what was sent. */
+class FakeXhr {
+  static reply: unknown = null
+  static sent: { url: string; body: FormData }[] = []
+  upload: {
+    onprogress: ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null
+  } = { onprogress: null }
+  onload: (() => void) | null = null
+  onerror: (() => void) | null = null
+  responseText = ''
+  private url = ''
+  open(_method: string, url: string) {
+    this.url = url
+  }
+  send(body: FormData) {
+    FakeXhr.sent.push({ url: this.url, body })
+    setTimeout(() => {
+      this.upload.onprogress?.({ lengthComputable: true, loaded: 1, total: 1 })
+      this.responseText = JSON.stringify(FakeXhr.reply)
+      this.onload?.()
+    }, 0)
+  }
+}
+
+function exportCsv(): string {
+  let csv = 'Time,Channel A,Channel B\n(ms),(V),(V)\n\n'
+  for (let i = 0; i < 600; i++) {
+    csv += `${(i * 0.05).toFixed(3)},${Math.floor(i / 25) % 2 === 0 ? 0.2 : 4.8},${Math.sin(i / 40).toFixed(3)}\n`
+  }
+  return csv
+}
+const PREVIEW = previewCsv(exportCsv()) as WaveformPreview
+const GOLF = { id: 'v1', make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' }
+
+function waveform(patch: Partial<FaultCodeWaveform> = {}): FaultCodeWaveform {
+  return {
+    id: '0190f3c2-7d1a-7000-8000-0000000000a1',
+    code: 'P0420',
+    meaning: null,
+    kind: 'good',
+    title: 'Crankshaft sensor',
+    condition: 'idle',
+    note: 'Back-probed pin 2 at the sensor plug.',
+    lang: 'en',
+    vehicle: { make: 'Skoda', model: 'Octavia', year: 2016 },
+    preview: PREVIEW,
+    files: [
+      { role: 'csv', name: 'export.csv', size: 24_000, width: null, height: null },
+      { role: 'psdata', name: 'crank idle.psdata', size: 1_200_000, width: null, height: null },
+    ],
+    createdAt: '2026-11-04T08:00:00Z',
+    ...patch,
+  }
+}
+
+function pageData(waveforms: CodeWaveforms, patch: Partial<FaultCodePageData> = {}) {
+  return {
+    code: 'P0420',
+    connected: true,
+    connectable: true,
+    available: true,
+    detail: {
+      code: 'P0420',
+      state: 'found',
+      range: 'standard',
+      title: 'Catalyst System Efficiency Below Threshold Bank 1',
+      source: 'generic',
+      make: null,
+      lang: 'en',
+      machineTranslated: false,
+      verified: true,
+      version: 2,
+      flags: { warningLight: true, emissions: true, driveCycle: false, limpMode: false },
+      description: null,
+      sections: [],
+      causes: [],
+      related: [],
+      pendingCheck: false,
+    },
+    jobs: [],
+    fixes: { available: true, published: [], own: [] },
+    report: null,
+    vehicle: null,
+    waveforms,
+    ...patch,
+  } as FaultCodePageData
+}
+
+const renderPage = (data: FaultCodePageData) =>
+  render(
+    <ConfirmProvider>
+      <TooltipProvider>
+        <FaultCodeDetail data={data} layout="page" />
+      </TooltipProvider>
+    </ConfirmProvider>
+  )
+
+function card() {
+  return within(
+    screen.getByRole('heading', { name: /^Waveforms/ }).closest('[data-slot="card"]') as HTMLElement
+  )
+}
+
+beforeEach(() => {
+  FakeXhr.reply = null
+  FakeXhr.sent = []
+  vi.stubGlobal('XMLHttpRequest', FakeXhr)
+  vi.mocked(toast.error).mockReset()
+  vi.mocked(toast.success).mockReset()
+  h.withdraw.mockReset()
+  URL.createObjectURL = vi.fn(() => 'blob:preview')
+  URL.revokeObjectURL = vi.fn()
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('waveforms on a code page', () => {
+  it('lists known good and faulty recordings, and filters by which', () => {
+    const faulty = waveform({
+      id: '0190f3c2-7d1a-7000-8000-0000000000a2',
+      kind: 'faulty',
+      title: 'Crankshaft sensor, missing tooth',
+      condition: 'cranking',
+    })
+    renderPage(pageData({ available: true, published: [faulty, waveform()], own: [] }))
+    const waves = card()
+    // Known good first, each with how the engine ran and on what.
+    const titles = waves.getAllByText(/^Crankshaft sensor/).map((el) => el.textContent)
+    expect(titles).toEqual(['Crankshaft sensor', 'Crankshaft sensor, missing tooth'])
+    expect(waves.getByText('Idle · 2016 Skoda Octavia')).toBeInTheDocument()
+    expect(waves.getByText('Cranking · 2016 Skoda Octavia')).toBeInTheDocument()
+    expect(waves.getAllByText('PicoScope file to download')).toHaveLength(2)
+
+    fireEvent.click(waves.getByRole('button', { name: 'Faulty' }))
+    expect(waves.queryByText('Crankshaft sensor')).not.toBeInTheDocument()
+    expect(waves.getByText('Crankshaft sensor, missing tooth')).toBeInTheDocument()
+  })
+
+  it('opens a faulty one beside a known good, with its notes and files to download', () => {
+    const good = waveform()
+    const faulty = waveform({
+      id: '0190f3c2-7d1a-7000-8000-0000000000a2',
+      kind: 'faulty',
+      title: 'Crankshaft sensor, missing tooth',
+    })
+    renderPage(pageData({ available: true, published: [good, faulty], own: [] }))
+    fireEvent.click(card().getByRole('button', { name: /missing tooth/ }))
+    const dialog = within(screen.getByRole('dialog'))
+    expect(
+      dialog.getByRole('heading', { name: 'Crankshaft sensor, missing tooth' })
+    ).toBeInTheDocument()
+    // The known good one is drawn under it to compare.
+    expect(dialog.getByRole('combobox')).toHaveTextContent('Known good · Crankshaft sensor')
+    expect(dialog.getAllByRole('img', { name: /Waveform of Channel A, Channel B/ })).toHaveLength(2)
+    expect(dialog.getByText('Back-probed pin 2 at the sensor plug.')).toBeInTheDocument()
+    const recording = dialog.getByRole('link', { name: /PicoScope recording/ })
+    expect(recording).toHaveAttribute(
+      'href',
+      `/api/protected/fault-codes/waveforms/${faulty.id}/files/psdata`
+    )
+    expect(recording).toHaveAttribute('download', 'crank idle.psdata')
+    // Not this workshop's: nothing to withdraw.
+    expect(dialog.queryByRole('button', { name: 'Withdraw' })).not.toBeInTheDocument()
+  })
+
+  it('invites sharing when there is none, and says so when the service does not answer', () => {
+    const { unmount } = renderPage(pageData({ available: true, published: [], own: [] }))
+    expect(card().getByText('No waveforms for P0420 yet')).toBeInTheDocument()
+    unmount()
+    renderPage(pageData({ available: false, published: [], own: [] }))
+    expect(screen.getByText('Waveforms are not available right now.')).toBeInTheDocument()
+  })
+
+  it("withdraws this workshop's own, waiting for review", async () => {
+    h.withdraw.mockResolvedValueOnce({ success: true, data: { withdrawn: true } })
+    const own: FaultCodeOwnWaveform = {
+      ...waveform(),
+      status: 'pending',
+      reason: null,
+      reviewedAt: null,
+    }
+    renderPage(pageData({ available: true, published: [], own: [own] }))
+    expect(card().getByText('Waiting for review')).toBeInTheDocument()
+    fireEvent.click(card().getByRole('button', { name: /Crankshaft sensor/ }))
+    fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Withdraw' }))
+    const confirm = await screen.findByRole('alertdialog')
+    fireEvent.click(within(confirm).getByRole('button', { name: 'Withdraw' }))
+    await waitFor(() => expect(card().getByText('No waveforms for P0420 yet')).toBeInTheDocument())
+    expect(h.withdraw).toHaveBeenCalledWith({ code: 'P0420', id: own.id })
+    expect(toast.success).toHaveBeenCalledWith('Waveform withdrawn')
+  })
+
+  it('shows the rest when the last of the kind filtered on is withdrawn', async () => {
+    h.withdraw.mockResolvedValueOnce({ success: true, data: { withdrawn: true } })
+    const faulty: FaultCodeOwnWaveform = {
+      ...waveform({
+        id: '0190f3c2-7d1a-7000-8000-0000000000a2',
+        kind: 'faulty',
+        title: 'Crankshaft sensor, missing tooth',
+      }),
+      status: 'pending',
+      reason: null,
+      reviewedAt: null,
+    }
+    renderPage(pageData({ available: true, published: [waveform()], own: [faulty] }))
+    fireEvent.click(card().getByRole('button', { name: 'Faulty' }))
+    expect(card().queryByText('Crankshaft sensor')).not.toBeInTheDocument()
+    fireEvent.click(card().getByRole('button', { name: /missing tooth/ }))
+    fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Withdraw' }))
+    const confirm = await screen.findByRole('alertdialog')
+    fireEvent.click(within(confirm).getByRole('button', { name: 'Withdraw' }))
+    // The known good one shows, not an empty list behind a filter that is gone.
+    await waitFor(() => expect(card().getByText('Crankshaft sensor')).toBeInTheDocument())
+  })
+})
+
+describe('sharing a waveform', () => {
+  async function openShare(data: FaultCodePageData) {
+    renderPage(data)
+    fireEvent.click(card().getByRole('button', { name: 'Share a waveform' }))
+    return within(await screen.findByRole('dialog'))
+  }
+
+  it('says what is missing, sorts the files as they land, draws the CSV and sends it all', async () => {
+    const shared: FaultCodeOwnWaveform = {
+      ...waveform({ id: '0190f3c2-7d1a-7000-8000-0000000000b1' }),
+      status: 'pending',
+      reason: null,
+      reviewedAt: null,
+    }
+    FakeXhr.reply = { success: true, data: shared }
+    const dialog = await openShare(
+      pageData({ available: true, published: [], own: [] }, { vehicle: GOLF })
+    )
+    expect(dialog.getByText('Share a waveform for P0420')).toBeInTheDocument()
+    // The car comes from the job.
+    expect(dialog.getByText('2014 Volkswagen Golf · CAXA')).toBeInTheDocument()
+    const send = dialog.getByRole('button', { name: 'Share waveform' })
+    expect(send).toBeDisabled()
+    expect(dialog.getByText('Pick known good or faulty')).toBeInTheDocument()
+
+    fireEvent.click(dialog.getByRole('button', { name: /Known good/ }))
+    expect(dialog.getByText('Say what you measured')).toBeInTheDocument()
+    // A tap fills in what was measured.
+    fireEvent.click(dialog.getByRole('button', { name: 'Crankshaft sensor' }))
+    expect(dialog.getByLabelText('What did you measure?')).toHaveValue('Crankshaft sensor')
+    expect(dialog.getByText('Add a file or a picture')).toBeInTheDocument()
+    fireEvent.click(dialog.getByRole('button', { name: 'Idle' }))
+
+    const recording = new File(['pico'], 'crank idle.psdata')
+    const csv = new File([exportCsv()], 'export.csv', { type: 'text/csv' })
+    fireEvent.change(dialog.getByTestId('waveform-file-input'), {
+      target: { files: [recording, csv] },
+    })
+    // Each file is named for what it is, and the CSV is drawn as others will see it.
+    await waitFor(() =>
+      expect(dialog.getByText('This is how other workshops will see it.')).toBeInTheDocument()
+    )
+    expect(dialog.getByText('PicoScope recording')).toBeInTheDocument()
+    expect(dialog.getByText('CSV export')).toBeInTheDocument()
+    expect(dialog.getByRole('img', { name: /Waveform of Channel A, Channel B/ })).toBeVisible()
+    expect(send).toBeEnabled()
+
+    fireEvent.click(send)
+    await waitFor(() => expect(toast.success).toHaveBeenCalled())
+    expect(toast.success).toHaveBeenCalledWith('Thanks. Your waveform is waiting for review.')
+    const [{ url, body }] = FakeXhr.sent
+    expect(url).toBe('/api/protected/fault-codes/waveforms')
+    expect(body.get('code')).toBe('P0420')
+    expect(body.get('kind')).toBe('good')
+    expect(body.get('title')).toBe('Crankshaft sensor')
+    expect(body.get('condition')).toBe('idle')
+    expect(body.get('vehicleId')).toBe('v1')
+    expect((body.get('psdata') as File).name).toBe('crank idle.psdata')
+    expect((body.get('csv') as File).name).toBe('export.csv')
+    expect(body.get('image')).toBeNull()
+    // The dialog closes and the waveform waits for review on the page.
+    await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+    expect(card().getByText('Waiting for review')).toBeInTheDocument()
+  })
+
+  it('turns away a CSV that is not a scope export and a file that is none of the kinds', async () => {
+    const dialog = await openShare(pageData({ available: true, published: [], own: [] }))
+    fireEvent.click(dialog.getByRole('button', { name: /Faulty/ }))
+    fireEvent.change(dialog.getByLabelText('What did you measure?'), {
+      target: { value: 'Injector 3' },
+    })
+    fireEvent.change(dialog.getByTestId('waveform-file-input'), {
+      target: {
+        files: [
+          new File(['name,price\nbolt,2\n'], 'parts.csv', { type: 'text/csv' }),
+          new File(['x'], 'notes.docx'),
+        ],
+      },
+    })
+    await waitFor(() =>
+      expect(
+        dialog.getByText('This is not a scope export. Save it again from PicoScope as CSV.')
+      ).toBeInTheDocument()
+    )
+    expect(toast.error).toHaveBeenCalledWith(
+      'notes.docx is not a PicoScope file, a CSV export or a picture'
+    )
+    expect(dialog.getByText('Remove the CSV that is not a scope export')).toBeInTheDocument()
+    expect(dialog.getByRole('button', { name: 'Share waveform' })).toBeDisabled()
+    fireEvent.click(dialog.getByRole('button', { name: 'Remove parts.csv' }))
+    expect(dialog.getByText('Add a file or a picture')).toBeInTheDocument()
+  })
+
+  it('keeps what was filled in when sharing is paused', async () => {
+    FakeXhr.reply = { success: false, error: 'paused' }
+    const dialog = await openShare(pageData({ available: true, published: [], own: [] }))
+    fireEvent.click(dialog.getByRole('button', { name: /Known good/ }))
+    fireEvent.click(dialog.getByRole('button', { name: 'Lambda sensor' }))
+    fireEvent.change(dialog.getByTestId('waveform-file-input'), {
+      target: { files: [new File(['pico'], 'lambda.psdata')] },
+    })
+    await waitFor(() => expect(dialog.getByText('PicoScope recording')).toBeInTheDocument())
+    // Without a CSV, a tip says how others could see it without PicoScope.
+    expect(dialog.getByText(/add a CSV export too/)).toBeInTheDocument()
+    fireEvent.click(dialog.getByRole('button', { name: 'Share waveform' }))
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        'Sharing waveforms is paused right now. Try again later.'
+      )
+    )
+    expect(dialog.getByLabelText('What did you measure?')).toHaveValue('Lambda sensor')
+    expect(dialog.getByText('PicoScope recording')).toBeInTheDocument()
+  })
+})

+ 155 - 0
src/__tests__/features/integrations/torqvoice-codes.test.ts

@@ -199,6 +199,159 @@ describe('Torqvoice Codes lookups', () => {
     expect(without?.meaning?.description?.text).toContain('stores oxygen')
     expect(without?.meaning?.description?.text).toContain('stores oxygen')
   })
   })
 
 
+  it('reads research for a code, and maps what asking for one comes to', async () => {
+    const row = {
+      id: '0190f3c2-7d1a-7000-8000-0000000000aa',
+      code: 'BMW-2A82',
+      meaning: 'einlass-vanos',
+      status: 'pending',
+      findings: {
+        title: 'Inlet VANOS solenoid stuck',
+        meaning: 'The inlet camshaft did not reach its position.',
+        common_issues: [],
+        steps: ['Measure the solenoid: 6 to 8 ohms.', 'Swap the solenoids.'],
+        causes: [{ label: 'Solenoid', likelihood: 'certain' }],
+        advice: null,
+        sources: [
+          { url: 'https://bimmerforums.com/t/1', title: 'Fixed' },
+          { url: 'javascript:alert(1)', title: 'x' },
+        ],
+      },
+      reason: null,
+      requests: 2,
+      helpful: 1,
+      not_helpful: 0,
+      created_at: '2026-11-06T10:00:00Z',
+    }
+    fetchMock.mockResolvedValueOnce(reply(200, { available: true, research: [row] }))
+    const list = await connector.researchFor!(ctx(), { code: 'BMW-2A82' })
+    expect(fetchMock.mock.calls[0][0]).toBe('http://torqvoice-codes:8090/v1/research/BMW-2A82')
+    expect(list.available).toBe(true)
+    expect(list.research[0]).toMatchObject({
+      meaning: 'einlass-vanos',
+      notHelpful: 0,
+      findings: {
+        commonIssues: [],
+        causes: [{ label: 'Solenoid', likelihood: 'medium' }],
+        // Only web pages are sources.
+        sources: [{ url: 'https://bimmerforums.com/t/1', title: 'Fixed' }],
+      },
+    })
+
+    fetchMock.mockResolvedValueOnce(reply(200, { research: row }))
+    const done = await connector.research!(ctx(), {
+      code: 'BMW-2A82',
+      meaning: 'einlass-vanos',
+      workshopRef: 'org1',
+    })
+    expect(done).toMatchObject({ ok: true, research: { id: row.id } })
+    expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toMatchObject({
+      code: 'BMW-2A82',
+      meaning: 'einlass-vanos',
+      workshop_ref: 'org1',
+    })
+    for (const [status, reason] of [
+      [409, 'busy'],
+      [429, 'limited'],
+      [404, 'unavailable'],
+      [502, 'unavailable'],
+    ] as const) {
+      fetchMock.mockResolvedValueOnce(reply(status, { title: 'x', status }))
+      expect(await connector.research!(ctx(), { code: 'P0420', workshopRef: 'org1' })).toEqual({
+        ok: false,
+        reason,
+      })
+    }
+    // An older service without research: none, and not available.
+    fetchMock.mockResolvedValueOnce(reply(404, { title: 'Not Found', status: 404 }))
+    expect(await connector.researchFor!(ctx(), { code: 'P0420' })).toEqual({
+      available: false,
+      research: [],
+    })
+  })
+
+  it('lists the makes whose own codes are served, and none from a service without the list', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, { makes: ['BMW'] }))
+    expect(await connector.oemMakes!(ctx())).toEqual(['bmw'])
+    expect(fetchMock.mock.calls[0][0]).toBe('http://torqvoice-codes:8090/v1/oem')
+    fetchMock.mockResolvedValueOnce(reply(404, { title: 'Not Found', status: 404 }))
+    expect(await connector.oemMakes!(ctx())).toEqual([])
+  })
+
+  it('reads a BMW code with its meanings, and says null when BMW codes are not served', async () => {
+    fetchMock.mockResolvedValueOnce(
+      reply(200, {
+        make: 'bmw',
+        code: '2A82',
+        found: true,
+        meanings: [
+          {
+            text: 'Einlass-VANOS',
+            text_lang: 'de',
+            text_en: 'Inlet VANOS',
+            modules: [{ ref: 'msd80', family: 'DME', name: 'Motorsteuerung MSD80' }],
+            vehicles: [
+              { chassis: 'E90', model: '3 Series', body: 'Sedan', year_from: 2005, year_to: 2013 },
+            ],
+            engines: ['N54'],
+            matches: true,
+            lang: 'nb',
+            title: 'Innløps-VANOS',
+            description: 'Rens magnetventilen. Nyere type del.',
+            sections: [
+              { kind: 'resolution', text: 'Rens magnetventilen.' },
+              { kind: 'tips', text: 'Nyere type del.' },
+            ],
+            causes: [{ key: 'solenoid', likelihood: 'certain', label: 'Magnetventil' }],
+          },
+          {
+            text: 'Lambdasonden nach Katalysator, vertauscht',
+            text_lang: 'de',
+            modules: [],
+            vehicles: [],
+            engines: [],
+            matches: false,
+            lang: 'en',
+            title: '  ',
+            sections: [],
+            causes: [],
+          },
+        ],
+      })
+    )
+    const answer = await connector.oemCode!(ctx(), {
+      source: 'bmw',
+      code: '2A82',
+      engine: 'N54B30',
+      locale: 'nb',
+    })
+    expect(fetchMock.mock.calls[0][0]).toBe(
+      'http://torqvoice-codes:8090/v1/oem/bmw/codes/2A82?engine=N54B30&lang=nb'
+    )
+    expect(answer?.meanings[0]).toMatchObject({
+      text: 'Einlass-VANOS',
+      textEn: 'Inlet VANOS',
+      matches: true,
+      vehicles: [{ chassis: 'E90', model: '3 Series', yearFrom: 2005, yearTo: 2013 }],
+    })
+    // Our own text comes along; a section kind or likelihood the app does not know reads safely.
+    expect(answer?.meanings[0]).toMatchObject({
+      lang: 'nb',
+      title: 'Innløps-VANOS',
+      description: 'Rens magnetventilen. Nyere type del.',
+      sections: [
+        { kind: 'resolution', text: 'Rens magnetventilen.' },
+        { kind: 'other', text: 'Nyere type del.' },
+      ],
+      causes: [{ key: 'solenoid', likelihood: 'medium', label: 'Magnetventil' }],
+    })
+    // Without a title there is no text of ours.
+    expect(answer?.meanings[1]).toMatchObject({ lang: null, title: null, sections: [] })
+
+    fetchMock.mockResolvedValueOnce(reply(404, { title: 'Not Found', status: 404 }))
+    expect(await connector.oemCode!(ctx(), { source: 'bmw', code: '2A82' })).toBeNull()
+  })
+
   it('reads one code with details, and says null for a code the service does not have', async () => {
   it('reads one code with details, and says null for a code the service does not have', async () => {
     fetchMock.mockResolvedValueOnce(reply(200, P0420))
     fetchMock.mockResolvedValueOnce(reply(200, P0420))
     const answer = await connector.codeDetail!(ctx(), {
     const answer = await connector.codeDetail!(ctx(), {
@@ -367,6 +520,7 @@ describe('Torqvoice Codes shared fixes', () => {
       {
       {
         id: FIX.id,
         id: FIX.id,
         code: 'P0420',
         code: 'P0420',
+        meaning: null,
         lang: 'nb',
         lang: 'nb',
         text: FIX.text,
         text: FIX.text,
         vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
         vehicle: { make: 'Volkswagen', model: 'Golf', year: 2014, engine: 'CAXA' },
@@ -392,6 +546,7 @@ describe('Torqvoice Codes shared fixes', () => {
     const own = await connector.fixStatus!(ctx(), [FIX.id, FIX.id])
     const own = await connector.fixStatus!(ctx(), [FIX.id, FIX.id])
     expect(fetchMock.mock.calls[1][0]).toBe(`http://torqvoice-codes:8090/v1/fixes?ids=${FIX.id}`)
     expect(fetchMock.mock.calls[1][0]).toBe(`http://torqvoice-codes:8090/v1/fixes?ids=${FIX.id}`)
     expect(own[0]).toMatchObject({
     expect(own[0]).toMatchObject({
+      meaning: null,
       vehicle: null,
       vehicle: null,
       status: 'rejected',
       status: 'rejected',
       reason: 'The photo shows a plate.',
       reason: 'The photo shows a plate.',

+ 58 - 0
src/app/(authenticated)/fault-codes/oem/[make]/[code]/page.tsx

@@ -0,0 +1,58 @@
+import { notFound, redirect } from 'next/navigation'
+import { PageHeader } from '@/components/page-header'
+import { getOemFaultCode } from '@/features/fault-codes/Actions/getOemFaultCode'
+import { FaultCodeOemView } from '@/features/fault-codes/Components/FaultCodeOemView'
+import { normalizeMakeCode } from '@/features/fault-codes/Lib/codes'
+import { isMakeKey } from '@/features/fault-codes/Lib/makes'
+import { isSupportEnabled } from '@/lib/support'
+
+/**
+ * A make's own code's page (/fault-codes/oem/bmw/2A82, /fault-codes/oem/ford/P1000), laid out as every
+ * code's page: the meaning, likely causes, shared fixes and the jobs here,
+ * with where the make uses it and every meaning the code has. `?vehicle=<id>`
+ * puts the meanings that fit that car first.
+ */
+export default async function OemFaultCodePage({
+  params,
+  searchParams,
+}: {
+  params: Promise<{ make: string; code: string }>
+  searchParams: Promise<{ vehicle?: string }>
+}) {
+  const [{ make, code: raw }, { vehicle }] = await Promise.all([params, searchParams])
+  const source = make.toLowerCase()
+  if (!isMakeKey(source)) notFound()
+  const code = normalizeMakeCode(source, decodeURIComponent(raw))
+  if (!code) notFound()
+  // One address per code: 0x2a82 is 2A82, p1000 is P1000.
+  if (code !== raw || source !== make)
+    redirect(
+      `/fault-codes/oem/${source}/${code}${vehicle ? `?vehicle=${encodeURIComponent(vehicle)}` : ''}`
+    )
+
+  const [result, supportEnabled] = await Promise.all([
+    getOemFaultCode({ source, code, vehicleId: vehicle ?? null, fixes: true }),
+    isSupportEnabled(),
+  ])
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <div className="mx-auto w-full max-w-[100rem]">
+          {result.success && result.data ? (
+            <FaultCodeOemView
+              key={code}
+              data={result.data}
+              layout="page"
+              supportEnabled={supportEnabled}
+            />
+          ) : (
+            <div className="flex h-[50vh] items-center justify-center">
+              <p className="text-muted-foreground">{result.error}</p>
+            </div>
+          )}
+        </div>
+      </div>
+    </>
+  )
+}

+ 5 - 0
src/app/(authenticated)/fault-codes/page.tsx

@@ -24,6 +24,11 @@ export default async function FaultCodesPage({
     getFaultCodeOverview({ query: q.slice(0, 100) }),
     getFaultCodeOverview({ query: q.slice(0, 100) }),
     isSupportEnabled(),
     isSupportEnabled(),
   ])
   ])
+  // So does a make's own code (BMW's 283700) that one make has.
+  const oemHits = result.success ? (result.data?.oemHits ?? []) : []
+  if (oemHits.length === 1 && oemHits[0].code === q.trim().toUpperCase())
+    redirect(`/fault-codes/oem/${oemHits[0].make}/${oemHits[0].code}`)
+
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
     return (
     return (
       <>
       <>

+ 1 - 0
src/app/(authenticated)/layout.tsx

@@ -381,6 +381,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
                                 features={features}
                                 features={features}
                                 tireHotelEnabled={tireHotelEnabled}
                                 tireHotelEnabled={tireHotelEnabled}
                                 aiEnabled={aiEnabled}
                                 aiEnabled={aiEnabled}
+                                faultCodesEnabled={faultCodesEnabled}
                                 visibleSubjects={visibleSubjects}
                                 visibleSubjects={visibleSubjects}
                                 announcement={announcements[0] ?? null}
                                 announcement={announcements[0] ?? null}
                                 isAdminOrOwner={isOwnerOrAdmin}
                                 isAdminOrOwner={isOwnerOrAdmin}

+ 10 - 2
src/app/(public)/p/[token]/fix-handoff-client.tsx

@@ -27,14 +27,22 @@ export function FixHandoffClient({
   token,
   token,
   brand,
   brand,
   code,
   code,
+  purpose = 'fix',
   problem,
   problem,
 }: {
 }: {
   token: string
   token: string
   brand: { name: string; logoUrl: string | null } | null
   brand: { name: string; logoUrl: string | null } | null
   code: string | null
   code: string | null
+  /** A fix's photos, or one picture of the scope screen for a waveform. */
+  purpose?: 'fix' | 'waveform'
   problem?: 'invalid' | 'expired'
   problem?: 'invalid' | 'expired'
 }) {
 }) {
-  const t = useTranslations('faultCodes.phone')
+  const phone = useTranslations('faultCodes.phone')
+  // A waveform's code has its own words for the few lines that differ.
+  const t = (key: string, values?: Record<string, string | number>) =>
+    purpose === 'waveform' && ['title', 'forCode', 'intro', 'full', 'done', 'added'].includes(key)
+      ? phone(`waveform.${key}` as Parameters<typeof phone>[0], values)
+      : phone(key as Parameters<typeof phone>[0], values)
   const [logoFailed, setLogoFailed] = useState(false)
   const [logoFailed, setLogoFailed] = useState(false)
   const [count, setCount] = useState(0)
   const [count, setCount] = useState(0)
   const [max, setMax] = useState(4)
   const [max, setMax] = useState(4)
@@ -177,7 +185,7 @@ export function FixHandoffClient({
                   ref={library}
                   ref={library}
                   type="file"
                   type="file"
                   accept="image/*"
                   accept="image/*"
-                  multiple
+                  multiple={purpose !== 'waveform'}
                   hidden
                   hidden
                   onChange={(e) => void send(e.target.files)}
                   onChange={(e) => void send(e.target.files)}
                 />
                 />

+ 17 - 2
src/app/(public)/p/[token]/page.tsx

@@ -184,8 +184,23 @@ async function InspectionHandoffPage({
 async function FixHandoffPage({ token }: { token: string }) {
 async function FixHandoffPage({ token }: { token: string }) {
   const check = verifyFixHandoffToken(token)
   const check = verifyFixHandoffToken(token)
   if (!check.ok) {
   if (!check.ok) {
-    return <FixHandoffClient token={token} brand={null} code={null} problem={check.reason} />
+    return (
+      <FixHandoffClient
+        token={token}
+        brand={null}
+        code={null}
+        purpose="fix"
+        problem={check.reason}
+      />
+    )
   }
   }
   const brand = await workshopBrand(check.handoff.organizationId)
   const brand = await workshopBrand(check.handoff.organizationId)
-  return <FixHandoffClient token={token} brand={brand} code={check.handoff.code} />
+  return (
+    <FixHandoffClient
+      token={token}
+      brand={brand}
+      code={check.handoff.code.replace(/^([A-Z]{2,8})-/, '$1 ')}
+      purpose={check.handoff.purpose}
+    />
+  )
 }
 }

+ 10 - 0
src/app/api/protected/fault-codes/fixes/route.ts

@@ -8,6 +8,7 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { locales } from '@/i18n/config'
 import { locales } from '@/i18n/config'
 import { discardDraft, readDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
 import { discardDraft, readDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
+import { fixMakesFor, splitFixCode, vehicleCarriesMake } from '@/features/fault-codes/Lib/codes'
 import { shareFix, vehicleForCodes } from '@/features/fault-codes/Lib/service.server'
 import { shareFix, vehicleForCodes } from '@/features/fault-codes/Lib/service.server'
 import type { FaultCodeVehicle } from '@/features/integrations/Lib/types'
 import type { FaultCodeVehicle } from '@/features/integrations/Lib/types'
 import {
 import {
@@ -82,6 +83,7 @@ export async function POST(request: NextRequest) {
   }
   }
   const parsed = shareFixSchema.safeParse({
   const parsed = shareFixSchema.safeParse({
     code: text('code'),
     code: text('code'),
+    meaning: text('meaning') || undefined,
     text: text('text'),
     text: text('text'),
     vehicleId: text('vehicleId') || undefined,
     vehicleId: text('vehicleId') || undefined,
     draftId: text('draftId') || undefined,
     draftId: text('draftId') || undefined,
@@ -121,9 +123,17 @@ export async function POST(request: NextRequest) {
   } else if (data.make || data.model || data.year) {
   } else if (data.make || data.model || data.year) {
     vehicle = { make: data.make, model: data.model, year: data.year }
     vehicle = { make: data.make, model: data.model, year: data.year }
   }
   }
+  // A make's own code is only on that make's cars: a BMW code on a Toyota is a typo.
+  const makes = fixMakesFor(data.code)
+  const codeMake = splitFixCode(data.code)?.make
+  if (codeMake && vehicle?.make && !vehicleCarriesMake(codeMake, vehicle.make))
+    return fail(400, 'invalid')
+  // Only a make's own code has meanings to tell apart.
+  if (data.meaning && !makes) return fail(400, 'invalid')
 
 
   const result = await shareFix(ctx.organizationId, {
   const result = await shareFix(ctx.organizationId, {
     code: data.code,
     code: data.code,
+    meaning: data.meaning,
     text: data.text,
     text: data.text,
     lang: lang.data,
     lang: lang.data,
     vehicle,
     vehicle,

+ 41 - 0
src/app/api/protected/fault-codes/import/check/route.ts

@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { fail, readImportRequest } from '@/features/fault-codes/Lib/codeImportRequest.server'
+import { checkCodeImportFor } from '@/features/fault-codes/Lib/service.server'
+
+/**
+ * What each code in an import file would do, before anything is sent for review: new for the
+ * make, a change to the text there is, the same, or a problem to fix in the file. Nothing is
+ * kept. A route rather than a server action because it carries the file.
+ */
+export async function POST(request: NextRequest) {
+  const read = await readImportRequest(request)
+  if (!read.ok) return read.response
+  const { organizationId, fields, file } = read
+  const result = await checkCodeImportFor(organizationId, {
+    make: fields.make,
+    lang: fields.lang,
+    rows: file.rows,
+  })
+  if (!result.ok) {
+    if (result.reason === 'too_large') return fail(413, 'file', 'too_many')
+    if (result.reason === 'invalid') return fail(400, 'invalid')
+    return fail(502, 'unavailable')
+  }
+  return NextResponse.json({
+    success: true,
+    data: {
+      counts: result.counts,
+      // Each row with the title the file gives it, so the list reads without the file.
+      rows: result.rows.map((r) => ({
+        ...r,
+        title: file.rows[r.n - 1]?.title ?? null,
+      })),
+      // What a JSON file says of itself, for the dialog to offer.
+      meta: {
+        make: file.make ?? null,
+        language: file.language ?? null,
+        source: file.source ?? null,
+      },
+    },
+  })
+}

+ 33 - 0
src/app/api/protected/fault-codes/import/example/route.ts

@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { spreadsheetTemplateResponse } from '@/features/import/Lib/template'
+import {
+  EXAMPLE_HEADERS,
+  exampleJson,
+  exampleSheetRows,
+} from '@/features/fault-codes/Lib/codeImportFile.server'
+
+/**
+ * The example file for importing fault codes, as an Excel workbook, a CSV or JSON: two codes
+ * with every column filled in or left out, so a workshop sees the shape and types over it.
+ */
+export async function GET(request: NextRequest) {
+  const ctx = await getAuthContext()
+  if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  const format = request.nextUrl.searchParams.get('format')
+  if (format === 'json') {
+    return new NextResponse(exampleJson(), {
+      headers: {
+        'Content-Type': 'application/json; charset=utf-8',
+        'Content-Disposition': 'attachment; filename="torqvoice-fault-codes-example.json"',
+      },
+    })
+  }
+  return spreadsheetTemplateResponse({
+    fileBase: 'torqvoice-fault-codes-example',
+    sheetName: 'Fault codes',
+    headers: EXAMPLE_HEADERS,
+    rows: exampleSheetRows(),
+    format: format === 'csv' ? 'csv' : 'xlsx',
+  })
+}

+ 42 - 0
src/app/api/protected/fault-codes/import/route.ts

@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { isDemoMode } from '@/lib/demo'
+import { fail, readImportRequest } from '@/features/fault-codes/Lib/codeImportRequest.server'
+import { codeImportsFor, sendCodeImportFor } from '@/features/fault-codes/Lib/service.server'
+
+/**
+ * Sends an import's new and changed codes to the code service, where a person reviews each
+ * before any workshop sees it. The sender confirms they may share what is in the file. The
+ * file is read again here rather than trusted from the check.
+ */
+export async function POST(request: NextRequest) {
+  if (isDemoMode) return fail(403, 'demo')
+  const read = await readImportRequest(request)
+  if (!read.ok) return read.response
+  const { organizationId, fields, file, fileName, form } = read
+  if (form.get('rights') !== 'yes') return fail(400, 'rights')
+  const result = await sendCodeImportFor(organizationId, {
+    make: fields.make,
+    lang: fields.lang,
+    source: fields.source ?? file.source,
+    fileName,
+    rows: file.rows,
+  })
+  if (!result.ok) {
+    switch (result.reason) {
+      case 'paused':
+        return fail(503, 'paused')
+      case 'nothing':
+        return fail(400, 'nothing')
+      case 'too_large':
+        return fail(413, 'file', 'too_many')
+      case 'invalid':
+        return fail(400, 'invalid')
+      default:
+        return fail(502, 'unavailable')
+    }
+  }
+  return NextResponse.json({
+    success: true,
+    data: { id: result.id, counts: result.counts, imports: await codeImportsFor(organizationId) },
+  })
+}

+ 52 - 0
src/app/api/protected/fault-codes/waveforms/[id]/files/[role]/route.ts

@@ -0,0 +1,52 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { getCachedMembership } from '@/lib/cached-session'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { sharedWaveformFile } from '@/features/fault-codes/Lib/service.server'
+
+const paramsSchema = z.object({
+  id: z.string().uuid(),
+  role: z.enum(['psdata', 'pssettings', 'csv', 'image']),
+})
+
+/**
+ * One file of a shared waveform, from the code service: a published one's, or this
+ * workshop's own while it waits for review. The recording and the settings download under
+ * the name they were shared with, to open in PicoScope; the picture shows in the page
+ * (`?size=thumb` for the small one). Streamed through; nothing is kept here.
+ */
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string; role: string }> }
+) {
+  const ctx = await getAuthContext()
+  if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  const membership = ctx.isAdmin ? null : await getCachedMembership(ctx.userId)
+  const allowed =
+    ctx.isAdmin ||
+    hasPermission(membership?.customRole?.permissions ?? [], {
+      action: PermissionAction.READ,
+      subject: PermissionSubject.VEHICLES,
+    })
+  if (!allowed) return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+
+  const parsed = paramsSchema.safeParse(await params)
+  if (!parsed.success) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  const size =
+    parsed.data.role === 'image' && request.nextUrl.searchParams.get('size') === 'thumb'
+      ? 'thumb'
+      : 'full'
+  const file = await sharedWaveformFile(ctx.organizationId, { ...parsed.data, size })
+  if (!file) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  const headers: Record<string, string> = {
+    'Content-Type': parsed.data.role === 'image' ? 'image/jpeg' : file.contentType,
+    'Cache-Control': 'private, max-age=86400',
+    'X-Content-Type-Options': 'nosniff',
+  }
+  if (parsed.data.role !== 'image' && file.disposition) {
+    headers['Content-Disposition'] = file.disposition
+  }
+  if (file.length) headers['Content-Length'] = String(file.length)
+  return new Response(file.body, { headers })
+}

+ 176 - 0
src/app/api/protected/fault-codes/waveforms/route.ts

@@ -0,0 +1,176 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { getCachedMembership } from '@/lib/cached-session'
+import { isDemoMode } from '@/lib/demo'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { locales } from '@/i18n/config'
+import { discardDraft, readDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
+import { fixMakesFor, splitFixCode, vehicleCarriesMake } from '@/features/fault-codes/Lib/codes'
+import { shareWaveform, vehicleForCodes } from '@/features/fault-codes/Lib/service.server'
+import type {
+  FaultCodeVehicle,
+  FaultCodeWaveformInput,
+  WaveformFileRole,
+} from '@/features/integrations/Lib/types'
+import {
+  MAX_WAVEFORM_IMAGE_BYTES,
+  MAX_WAVEFORM_RECORDING_BYTES,
+  MAX_WAVEFORM_SETTINGS_BYTES,
+  shareWaveformSchema,
+} from '@/features/fault-codes/Schema/faultCodeSchema'
+
+/** Each file at its limit and room for the rest of the form. */
+const MAX_BODY =
+  2 * MAX_WAVEFORM_RECORDING_BYTES +
+  MAX_WAVEFORM_SETTINGS_BYTES +
+  MAX_WAVEFORM_IMAGE_BYTES +
+  1024 * 1024
+
+const LIMITS: Record<WaveformFileRole, number> = {
+  psdata: MAX_WAVEFORM_RECORDING_BYTES,
+  csv: MAX_WAVEFORM_RECORDING_BYTES,
+  pssettings: MAX_WAVEFORM_SETTINGS_BYTES,
+  image: MAX_WAVEFORM_IMAGE_BYTES,
+}
+
+/** Why sharing did not go through, as a code the dialog turns into words. */
+export type ShareWaveformError =
+  | 'unauthorized'
+  | 'forbidden'
+  | 'demo'
+  | 'restoring'
+  | 'invalid'
+  | 'photo'
+  | 'csv'
+  | 'too_large'
+  | 'no_file'
+  | 'paused'
+  | 'unavailable'
+
+function fail(status: number, error: ShareWaveformError, message?: string) {
+  return NextResponse.json({ success: false, error, message }, { status })
+}
+
+/**
+ * "Share a waveform" from a fault code's page: what it shows, known good or faulty, the car,
+ * and whatever files the technician has (the recording, its settings, a CSV export, a picture
+ * of the screen, or a photo from the phone). Sent on to the code service, which keeps it for a
+ * person to review; only the waveform's id is kept here, for the page to show its review.
+ *
+ * A route rather than a server action because it carries files.
+ */
+export async function POST(request: NextRequest) {
+  const ctx = await getAuthContext()
+  if (!ctx) return fail(401, 'unauthorized')
+  const membership = ctx.isAdmin ? null : await getCachedMembership(ctx.userId)
+  const allowed =
+    ctx.isAdmin ||
+    hasPermission(membership?.customRole?.permissions ?? [], {
+      action: PermissionAction.UPDATE,
+      subject: PermissionSubject.VEHICLES,
+    })
+  if (!allowed) return fail(403, 'forbidden')
+  if (isDemoMode) return fail(403, 'demo')
+  if (isRestoring(ctx.organizationId)) return fail(409, 'restoring', RESTORE_IN_PROGRESS_MESSAGE)
+  try {
+    assertContentLength(request, MAX_BODY)
+  } catch {
+    return fail(413, 'too_large')
+  }
+
+  let form: FormData
+  try {
+    form = await request.formData()
+  } catch {
+    return fail(400, 'invalid')
+  }
+  const text = (name: string) => {
+    const value = form.get(name)
+    return typeof value === 'string' ? value : undefined
+  }
+  const parsed = shareWaveformSchema.safeParse({
+    code: text('code'),
+    meaning: text('meaning') || undefined,
+    kind: text('kind'),
+    title: text('title'),
+    condition: text('condition') || undefined,
+    note: text('note') || undefined,
+    vehicleId: text('vehicleId') || undefined,
+    draftId: text('draftId') || undefined,
+    make: text('make'),
+    model: text('model'),
+    year: text('year'),
+  })
+  const lang = z.enum(locales).safeParse(text('lang'))
+  if (!parsed.success || !lang.success) return fail(400, 'invalid')
+  const data = parsed.data
+
+  const files: FaultCodeWaveformInput['files'] = []
+  for (const role of ['psdata', 'pssettings', 'csv', 'image'] as const) {
+    const file = form.get(role)
+    if (file === null) continue
+    if (!(file instanceof File) || file.size === 0) return fail(400, 'invalid')
+    if (file.size > LIMITS[role]) return fail(413, 'too_large')
+    files.push({ role, blob: file, name: file.name })
+  }
+  // A photo of the screen the phone took, already compressed on the way in.
+  if (data.draftId && !files.some((f) => f.role === 'image')) {
+    const [fromPhone] = await readDraftPhotos(ctx.organizationId, data.draftId)
+    if (fromPhone) {
+      files.push({
+        role: 'image',
+        blob: new Blob([new Uint8Array(fromPhone)], { type: 'image/jpeg' }),
+        name: 'screen.jpg',
+      })
+    }
+  }
+  if (!files.some((f) => f.role !== 'pssettings')) return fail(400, 'no_file')
+
+  let vehicle: FaultCodeVehicle | null = null
+  if (data.vehicleId) {
+    const known = await vehicleForCodes(ctx.organizationId, data.vehicleId)
+    if (known) {
+      vehicle = { make: known.make, model: known.model, year: known.year, engine: known.engine }
+    }
+  } else if (data.make || data.model || data.year) {
+    vehicle = { make: data.make, model: data.model, year: data.year }
+  }
+  // A make's own code is only on that make's cars.
+  const makes = fixMakesFor(data.code)
+  const codeMake = splitFixCode(data.code)?.make
+  if (codeMake && vehicle?.make && !vehicleCarriesMake(codeMake, vehicle.make))
+    return fail(400, 'invalid')
+  if (data.meaning && !makes) return fail(400, 'invalid')
+
+  const result = await shareWaveform(ctx.organizationId, {
+    code: data.code,
+    meaning: data.meaning,
+    kind: data.kind,
+    title: data.title,
+    condition: data.condition,
+    note: data.note,
+    lang: lang.data,
+    vehicle: vehicle ?? undefined,
+    files,
+  })
+  if (result.ok) {
+    if (data.draftId) await discardDraft(ctx.organizationId, data.draftId)
+    return NextResponse.json({ success: true, data: result.waveform })
+  }
+  switch (result.reason) {
+    case 'photo':
+      return fail(400, 'photo')
+    case 'invalid':
+      // The service refuses a CSV that is not a scope export, or a file of the wrong kind.
+      return fail(400, files.some((f) => f.role === 'csv') ? 'csv' : 'invalid')
+    case 'too_large':
+      return fail(413, 'too_large')
+    case 'paused':
+      return fail(503, 'paused')
+    default:
+      return fail(502, 'unavailable')
+  }
+}

+ 10 - 5
src/app/api/public/fix-handoff/[token]/route.ts

@@ -4,7 +4,11 @@ import { compressPhoto } from '@/lib/image-upload.server'
 import { verifyFixHandoffToken } from '@/lib/photo-handoff'
 import { verifyFixHandoffToken } from '@/lib/photo-handoff'
 import { rateLimit } from '@/lib/rate-limit'
 import { rateLimit } from '@/lib/rate-limit'
 import { addDraftPhoto, listDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
 import { addDraftPhoto, listDraftPhotos } from '@/features/fault-codes/Lib/fixDrafts.server'
-import { MAX_FIX_PHOTOS } from '@/features/fault-codes/Schema/faultCodeSchema'
+import { MAX_FIX_PHOTOS, MAX_WAVEFORM_PHOTOS } from '@/features/fault-codes/Schema/faultCodeSchema'
+
+/** A fix takes up to four photos; a waveform one picture of the scope screen. */
+const maxFor = (purpose: 'fix' | 'waveform') =>
+  purpose === 'waveform' ? MAX_WAVEFORM_PHOTOS : MAX_FIX_PHOTOS
 
 
 const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic'])
 const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic'])
 /** What a phone camera sends before compression. */
 /** What a phone camera sends before compression. */
@@ -28,7 +32,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   const { token } = await params
   const { token } = await params
   const check = verifyFixHandoffToken(token)
   const check = verifyFixHandoffToken(token)
   if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
   if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
-  const { organizationId, draftId } = check.handoff
+  const { organizationId, draftId, purpose } = check.handoff
+  const max = maxFor(purpose)
   // No session here, so the restore freeze is checked on the route itself.
   // No session here, so the restore freeze is checked on the route itself.
   if (isRestoring(organizationId)) {
   if (isRestoring(organizationId)) {
     return NextResponse.json(
     return NextResponse.json(
@@ -51,9 +56,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
   } catch {
   } catch {
     return refuse(400, 'type')
     return refuse(400, 'type')
   }
   }
-  const added = await addDraftPhoto(organizationId, draftId, jpeg)
+  const added = await addDraftPhoto(organizationId, draftId, jpeg, max)
   if (!added) return refuse(409, 'limit')
   if (!added) return refuse(409, 'limit')
-  return NextResponse.json({ count: added.count, max: MAX_FIX_PHOTOS })
+  return NextResponse.json({ count: added.count, max })
 }
 }
 
 
 /** How many photos the draft holds, so a reopened phone page starts right. */
 /** How many photos the draft holds, so a reopened phone page starts right. */
@@ -62,5 +67,5 @@ export async function GET(_request: Request, { params }: { params: Promise<{ tok
   const check = verifyFixHandoffToken(token)
   const check = verifyFixHandoffToken(token)
   if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
   if (!check.ok) return refuse(check.reason === 'expired' ? 410 : 404, check.reason)
   const photos = await listDraftPhotos(check.handoff.organizationId, check.handoff.draftId)
   const photos = await listDraftPhotos(check.handoff.organizationId, check.handoff.draftId)
-  return NextResponse.json({ count: photos.length, max: MAX_FIX_PHOTOS })
+  return NextResponse.json({ count: photos.length, max: maxFor(check.handoff.purpose) })
 }
 }

+ 15 - 0
src/components/app-sidebar.tsx

@@ -71,6 +71,7 @@ import {
   Users,
   Users,
   UserRound,
   UserRound,
   Sparkles,
   Sparkles,
+  ScanSearch,
 } from 'lucide-react'
 } from 'lucide-react'
 import { useLocale, useTranslations } from 'next-intl'
 import { useLocale, useTranslations } from 'next-intl'
 import { switchOrganization } from '@/features/team/Actions/switchOrganization'
 import { switchOrganization } from '@/features/team/Actions/switchOrganization'
@@ -132,6 +133,7 @@ export function AppSidebar({
   features,
   features,
   tireHotelEnabled = false,
   tireHotelEnabled = false,
   aiEnabled = false,
   aiEnabled = false,
+  faultCodesEnabled = false,
   isAdminOrOwner = false,
   isAdminOrOwner = false,
   visibleSubjects,
   visibleSubjects,
   announcement = null,
   announcement = null,
@@ -147,6 +149,8 @@ export function AppSidebar({
   tireHotelEnabled?: boolean
   tireHotelEnabled?: boolean
   /** An AI vendor is connected and the plan includes AI, so the assistant page can answer. */
   /** An AI vendor is connected and the plan includes AI, so the assistant page can answer. */
   aiEnabled?: boolean
   aiEnabled?: boolean
+  /** The code service is connected, so the fault code page can answer. */
+  faultCodesEnabled?: boolean
   isAdminOrOwner?: boolean
   isAdminOrOwner?: boolean
   visibleSubjects?: string[]
   visibleSubjects?: string[]
   /** The one product announcement to show, worked out on the server. */
   /** The one product announcement to show, worked out on the server. */
@@ -221,6 +225,17 @@ export function AppSidebar({
       count: counts?.inspections,
       count: counts?.inspections,
       countKey: 'sidebar.badges.inspections',
       countKey: 'sidebar.badges.inspections',
     },
     },
+    // Only once the code service is connected: without it the page can only say so.
+    ...(faultCodesEnabled
+      ? [
+          {
+            titleKey: 'sidebar.faultCodes' as const,
+            url: '/fault-codes',
+            icon: ScanSearch,
+            subject: 'vehicles',
+          },
+        ]
+      : []),
     {
     {
       titleKey: 'sidebar.calendar' as const,
       titleKey: 'sidebar.calendar' as const,
       url: '/calendar',
       url: '/calendar',

+ 2 - 2
src/components/page-header.tsx

@@ -227,8 +227,8 @@ export function PageHeader() {
   let segments = breadcrumbMap[pathname]
   let segments = breadcrumbMap[pathname]
 
 
   if (!segments) {
   if (!segments) {
-    // /fault-codes/[code]
-    if (/^\/fault-codes\/[^/]+$/.test(pathname)) {
+    // /fault-codes/[code] and a make's own code, /fault-codes/oem/[make]/[code]
+    if (/^\/fault-codes\/([^/]+|oem\/[^/]+\/[^/]+)$/.test(pathname)) {
       segments = [{ key: 'faultCodes', href: '/fault-codes' }, { key: 'faultCode' }]
       segments = [{ key: 'faultCodes', href: '/fault-codes' }, { key: 'faultCode' }]
     }
     }
     // /tire-hotel/[id]
     // /tire-hotel/[id]

+ 7 - 1
src/features/fault-codes/Actions/addFaultCodeFindings.ts

@@ -6,7 +6,7 @@ import { logAudit } from '@/lib/audit'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
-import { faultLine } from '../Lib/codes'
+import { faultLine, isOemCode, oemSourceFor } from '../Lib/codes'
 import { findingWebhookFields } from '../Lib/findings'
 import { findingWebhookFields } from '../Lib/findings'
 import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
 import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
 import { addFaultCodeFindingsSchema } from '../Schema/faultCodeSchema'
 import { addFaultCodeFindingsSchema } from '../Schema/faultCodeSchema'
@@ -58,6 +58,12 @@ export async function addFaultCodeFindings(input: unknown) {
         vehicleForCodes(organizationId, job.vehicleId),
         vehicleForCodes(organizationId, job.vehicleId),
         resolveDocumentLocale(organizationId),
         resolveDocumentLocale(organizationId),
       ])
       ])
+      // A manufacturer code (BMW's 2A82) only means something on that make's
+      // cars; on any other car it is not added.
+      if (!oemSourceFor(vehicle?.make)) {
+        for (const code of [...wanted.keys()]) if (isOemCode(code)) wanted.delete(code)
+        if (wanted.size === 0) return { added: 0, skipped: data.codes.length, findings: [] }
+      }
       const { results } = await lookupMeanings(organizationId, [...wanted.keys()], vehicle, locale)
       const { results } = await lookupMeanings(organizationId, [...wanted.keys()], vehicle, locale)
       const meanings = new Map(results.map((r) => [r.code, r]))
       const meanings = new Map(results.map((r) => [r.code, r]))
 
 

+ 1 - 0
src/features/fault-codes/Actions/createFixPhoneLink.ts

@@ -18,6 +18,7 @@ export async function createFixPhoneLink(input: unknown) {
         code: data.code,
         code: data.code,
         draftId: data.draftId,
         draftId: data.draftId,
         userId,
         userId,
+        purpose: data.purpose ?? 'fix',
       })
       })
       return { token, expiresAt: expiresAt.toISOString() }
       return { token, expiresAt: expiresAt.toISOString() }
     },
     },

+ 12 - 0
src/features/fault-codes/Actions/getCodeImports.ts

@@ -0,0 +1,12 @@
+'use server'
+
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { codeImportsFor } from '../Lib/service.server'
+
+/** This workshop's code imports, newest first, with how many codes were published. */
+export async function getCodeImports() {
+  return withAuth(async ({ organizationId }) => codeImportsFor(organizationId), {
+    requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+  })
+}

+ 18 - 5
src/features/fault-codes/Actions/getFaultCodeOverview.ts

@@ -6,13 +6,19 @@ import { getManifest } from '@/integrations/registry'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { recentCodes } from '../Lib/recent.server'
 import { recentCodes } from '../Lib/recent.server'
-import { faultCodesConnected, searchServiceCodes } from '../Lib/service.server'
+import {
+  faultCodesConnected,
+  oemMakesFor,
+  searchOemCodes,
+  searchServiceCodes,
+} from '../Lib/service.server'
 
 
 const inputSchema = z.object({ query: z.string().trim().max(100).default('') })
 const inputSchema = z.object({ query: z.string().trim().max(100).default('') })
 
 
 /**
 /**
  * The fault code page: whether the code service is connected, what it
  * The fault code page: whether the code service is connected, what it
- * finds for words like "lean bank 1", and the codes read lately in this
+ * finds for words like "lean bank 1" or a make's own code like BMW's 283700,
+ * which makes' own codes it serves, and the codes read lately in this
  * workshop, which come from its own jobs and show either way.
  * workshop, which come from its own jobs and show either way.
  */
  */
 export async function getFaultCodeOverview(input: unknown) {
 export async function getFaultCodeOverview(input: unknown) {
@@ -23,16 +29,23 @@ export async function getFaultCodeOverview(input: unknown) {
         faultCodesConnected(organizationId),
         faultCodesConnected(organizationId),
         recentCodes(organizationId),
         recentCodes(organizationId),
       ])
       ])
-      const search =
+      const locale = await getLocale()
+      const oemMakes = connected ? await oemMakesFor(organizationId) : []
+      const [search, oemHits] =
         connected && query
         connected && query
-          ? await searchServiceCodes(organizationId, query, await getLocale(), 25)
-          : { available: connected, hits: [] }
+          ? await Promise.all([
+              searchServiceCodes(organizationId, query, locale, 25),
+              searchOemCodes(organizationId, query, locale, oemMakes),
+            ])
+          : [{ available: connected, hits: [] }, []]
       return {
       return {
         connected,
         connected,
         connectable: getManifest('torqvoice-codes') !== null,
         connectable: getManifest('torqvoice-codes') !== null,
         query,
         query,
         available: search.available,
         available: search.available,
         hits: search.hits,
         hits: search.hits,
+        oemHits,
+        oemMakes,
         recent,
         recent,
       }
       }
     },
     },

+ 33 - 0
src/features/fault-codes/Actions/getOemFaultCode.ts

@@ -0,0 +1,33 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { loadOemCodePage } from '../Lib/page.server'
+import { oemCodeSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * A make's own code (BMW's 2A82) for its page and the sheet beside a job, as
+ * every code page reads it: its meanings, the jobs here and, on the page,
+ * shared fixes. The meanings fitting the job's car come first.
+ */
+export async function getOemFaultCode(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = oemCodeSchema.parse(input)
+      return loadOemCodePage(
+        organizationId,
+        data.source,
+        data.code,
+        data.vehicleId,
+        await getLocale(),
+        {
+          fixes: data.fixes === true,
+        }
+      )
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 2 - 2
src/features/fault-codes/Actions/lookupFaultCodes.ts

@@ -4,7 +4,7 @@ import { getLocale } from 'next-intl/server'
 import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
-import { normalizeFaultCode } from '../Lib/codes'
+import { normalizeFaultCode, normalizeOemCode } from '../Lib/codes'
 import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
 import { lookupMeanings, vehicleForCodes } from '../Lib/service.server'
 import { lookupFaultCodesSchema } from '../Schema/faultCodeSchema'
 import { lookupFaultCodesSchema } from '../Schema/faultCodeSchema'
 
 
@@ -18,7 +18,7 @@ export async function lookupFaultCodes(input: unknown) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const data = lookupFaultCodesSchema.parse(input)
       const data = lookupFaultCodesSchema.parse(input)
       const codes = data.codes
       const codes = data.codes
-        .map((c) => normalizeFaultCode(c))
+        .map((c) => normalizeFaultCode(c) ?? normalizeOemCode(c))
         .filter((c): c is string => c !== null)
         .filter((c): c is string => c !== null)
       const [vehicle, locale] = await Promise.all([
       const [vehicle, locale] = await Promise.all([
         vehicleForCodes(organizationId, data.vehicleId),
         vehicleForCodes(organizationId, data.vehicleId),

+ 19 - 0
src/features/fault-codes/Actions/rateFaultCodeResearch.ts

@@ -0,0 +1,19 @@
+'use server'
+
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { rateResearch } from '../Lib/service.server'
+import { rateResearchSchema } from '../Schema/faultCodeSchema'
+
+/** "Did this fix it?" on a researched fix: once per workshop, a second answer replaces it. */
+export async function rateFaultCodeResearch(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = rateResearchSchema.parse(input)
+      return { rated: await rateResearch(organizationId, data.id, data.helpful) }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 33 - 0
src/features/fault-codes/Actions/researchFaultCode.ts

@@ -0,0 +1,33 @@
+'use server'
+
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { requestResearch, vehicleForCodes } from '../Lib/service.server'
+import { researchFaultCodeSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * Researches a fix for a code, or one meaning of a make's code, with AI and web search at the
+ * code service, which keeps a specific, sourced answer for a person to check and records a
+ * miss otherwise. An answer already there comes back at once, for any workshop. Opened from a
+ * job, the job's car goes with it, never its plate or VIN.
+ */
+export async function researchFaultCode(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const data = researchFaultCodeSchema.parse(input)
+      const car = await vehicleForCodes(organizationId, data.vehicleId)
+      return requestResearch(organizationId, {
+        code: data.code,
+        meaning: data.meaning,
+        vehicle: car
+          ? { make: car.make, model: car.model, year: car.year, engine: car.engine ?? undefined }
+          : null,
+      })
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
+    }
+  )
+}

+ 29 - 12
src/features/fault-codes/Actions/searchFaultCodes.ts

@@ -4,8 +4,13 @@ import { getLocale } from 'next-intl/server'
 import { z } from 'zod'
 import { z } from 'zod'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
-import { looksLikeFaultCode, normalizeFaultCode } from '../Lib/codes'
-import { jobsWithCode, searchServiceCodes } from '../Lib/service.server'
+import {
+  looksLikeFaultCode,
+  looksLikeOemCode,
+  normalizeFaultCode,
+  normalizeOemCode,
+} from '../Lib/codes'
+import { jobsWithCode, searchOemCodes, searchServiceCodes } from '../Lib/service.server'
 
 
 const querySchema = z.string().max(100)
 const querySchema = z.string().max(100)
 
 
@@ -13,22 +18,34 @@ const querySchema = z.string().max(100)
  * The search palette's fault code groups, asked for beside the normal search
  * The search palette's fault code groups, asked for beside the normal search
  * so a slow answer from the code service never holds up vehicles, customers
  * so a slow answer from the code service never holds up vehicles, customers
  * or jobs. Only a query that looks like a code goes anywhere: the service's
  * or jobs. Only a query that looks like a code goes anywhere: the service's
- * codes for it, and for a whole code the jobs here where it was read, which
- * are the workshop's own data and come back with or without the service.
+ * codes for it (an OBD code, or a make's own like BMW's 283700), and for a
+ * whole code the jobs here where it was read, which are the workshop's own
+ * data and come back with or without the service.
  */
  */
 export async function searchFaultCodes(query: unknown) {
 export async function searchFaultCodes(query: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const q = querySchema.parse(query).trim()
       const q = querySchema.parse(query).trim()
-      if (!looksLikeFaultCode(q)) {
-        return { query: q, available: false, codes: [], jobs: [] }
+      const locale = await getLocale()
+      if (looksLikeFaultCode(q)) {
+        const code = normalizeFaultCode(q)
+        // A make can give an OBD-shaped code a meaning of its own (Ford's P1000).
+        const [service, oem, jobs] = await Promise.all([
+          searchServiceCodes(organizationId, q, locale),
+          searchOemCodes(organizationId, q, locale),
+          code ? jobsWithCode(organizationId, code, 6) : Promise.resolve([]),
+        ])
+        return { query: q, available: service.available, codes: service.hits, oem, jobs }
       }
       }
-      const code = normalizeFaultCode(q)
-      const [service, jobs] = await Promise.all([
-        searchServiceCodes(organizationId, q, await getLocale()),
-        code ? jobsWithCode(organizationId, code, 6) : Promise.resolve([]),
-      ])
-      return { query: q, available: service.available, codes: service.hits, jobs }
+      const oemCode = looksLikeOemCode(q) ? normalizeOemCode(q) : null
+      if (oemCode) {
+        const [oem, jobs] = await Promise.all([
+          searchOemCodes(organizationId, q, locale),
+          jobsWithCode(organizationId, oemCode, 6),
+        ])
+        return { query: q, available: true, codes: [], oem, jobs }
+      }
+      return { query: q, available: false, codes: [], oem: [], jobs: [] }
     },
     },
     {
     {
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],

+ 78 - 0
src/features/fault-codes/Actions/sendFaultCodeFeedback.ts

@@ -0,0 +1,78 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { getFromAddress, sendMail } from '@/lib/email'
+import { isCloudMode } from '@/lib/features'
+import { getSupportRecipient } from '@/lib/support'
+import { withAuth } from '@/lib/with-auth'
+import { buildSupportEmailHtml, supportReplyToAddress } from '@/features/support/Lib/supportRequest'
+import { improveFaultCodesSchema } from '../Schema/faultCodeSchema'
+
+/** A few messages an hour per person is plenty; more is a stuck button or a script. */
+const LIMIT = 5
+const WINDOW_MS = 60 * 60 * 1000
+const sent = new Map<string, number[]>()
+
+function allowed(userId: string, now: number): boolean {
+  const recent = (sent.get(userId) ?? []).filter((at) => now - at < WINDOW_MS)
+  if (recent.length >= LIMIT) {
+    sent.set(userId, recent)
+    return false
+  }
+  sent.set(userId, [...recent, now])
+  return true
+}
+
+const TOPICS = {
+  makes: 'Codes we are missing',
+  wrong: 'Something is wrong',
+  source: 'A source or link',
+  idea: 'An idea',
+} as const
+
+/**
+ * "Help us improve" on the fault code pages: a workshop tells the Torqvoice team where a
+ * make's codes are published, what is wrong, a link worth reading or an idea. Mailed through
+ * the platform mailer to the support address (post@torqvoice.com unless the admin set
+ * another), with the person as reply-to and the code on screen in the subject. Torqvoice
+ * Cloud only: a self-hosted install has nobody at the other end of that address.
+ */
+export async function sendFaultCodeFeedback(input: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    demoGuard()
+    if (!isCloudMode()) throw new Error('Messages to Torqvoice are sent from Torqvoice Cloud.')
+    const data = improveFaultCodesSchema.parse(input)
+    if (!allowed(userId, Date.now())) return { sent: false as const, reason: 'limited' as const }
+
+    const [organization, user] = await Promise.all([
+      db.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
+      db.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
+    ])
+    if (!user?.email) throw new Error('Your account has no email address to reply to.')
+
+    const subject = ['Fault codes', data.topic ? TOPICS[data.topic] : 'Help us improve', data.code]
+      .filter(Boolean)
+      .join(': ')
+    const html = buildSupportEmailHtml(subject, data.message, {
+      organizationName: organization?.name ?? 'Unknown organization',
+      organizationId,
+      userName: user.name,
+      userEmail: user.email,
+      pageUrl: data.page ?? null,
+      userAgent: null,
+      appVersion: process.env.APP_VERSION || null,
+      submittedAt: new Date().toISOString(),
+    })
+    // The platform mailer, not the workshop's own: this is mail to Torqvoice.
+    const [from, to] = await Promise.all([getFromAddress(), getSupportRecipient()])
+    await sendMail({
+      from,
+      to,
+      replyTo: supportReplyToAddress(user.name, user.email),
+      subject: `[Improve] ${subject}`,
+      html,
+    })
+    return { sent: true as const }
+  })
+}

+ 10 - 5
src/features/fault-codes/Actions/suggestFaultCodes.ts

@@ -4,21 +4,26 @@ import { getLocale } from 'next-intl/server'
 import { z } from 'zod'
 import { z } from 'zod'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
-import { searchServiceCodes } from '../Lib/service.server'
+import { searchOemCodes, searchServiceCodes } from '../Lib/service.server'
 
 
 const querySchema = z.string().trim().min(2).max(100)
 const querySchema = z.string().trim().min(2).max(100)
 
 
 /**
 /**
- * Matches while typing on the fault code page: a code prefix ("P04") or
- * words ("lean bank 1"), asked of the code service. Nothing is stored; a
+ * Matches while typing on the fault code page: a code prefix ("P04"), words
+ * ("lean bank 1") or a make's own code (BMW's 283700), asked of the code
+ * service. Nothing is stored; a
  * service that is down or not connected answers `available: false`.
  * service that is down or not connected answers `available: false`.
  */
  */
 export async function suggestFaultCodes(query: unknown) {
 export async function suggestFaultCodes(query: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const q = querySchema.parse(query)
       const q = querySchema.parse(query)
-      const found = await searchServiceCodes(organizationId, q, await getLocale(), 12)
-      return { query: q, available: found.available, hits: found.hits }
+      const locale = await getLocale()
+      const [found, oemHits] = await Promise.all([
+        searchServiceCodes(organizationId, q, locale, 12),
+        searchOemCodes(organizationId, q, locale),
+      ])
+      return { query: q, available: found.available, hits: found.hits, oemHits }
     },
     },
     {
     {
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],
       requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }],

+ 32 - 0
src/features/fault-codes/Actions/withdrawFaultCodeWaveform.ts

@@ -0,0 +1,32 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { withdrawSharedWaveform } from '../Lib/service.server'
+import { withdrawWaveformSchema } from '../Schema/faultCodeSchema'
+
+/**
+ * Takes back a waveform this workshop shared: the code service deletes it with its files,
+ * waiting for review or published. Only one this workshop shared can be taken back.
+ */
+export async function withdrawFaultCodeWaveform(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const data = withdrawWaveformSchema.parse(input)
+      const outcome = await withdrawSharedWaveform(organizationId, data.code, data.id)
+      if (outcome === 'unavailable') {
+        throw new Error('The waveform could not be taken back right now. Try again in a moment.')
+      }
+      revalidatePath('/fault-codes')
+      return { withdrawn: outcome === 'withdrawn' }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.VEHICLES },
+      ],
+    }
+  )
+}

+ 3 - 0
src/features/fault-codes/Components/DescriptionSections.tsx

@@ -16,6 +16,9 @@ const HEADINGS: readonly FaultCodeSectionKind[] = [
   'manufacturer',
   'manufacturer',
   'related',
   'related',
   'diagnosis',
   'diagnosis',
+  'common_issues',
+  'resolution',
+  'advisory',
   'other',
   'other',
 ]
 ]
 
 

+ 255 - 40
src/features/fault-codes/Components/FaultCodeDetail.tsx

@@ -8,6 +8,7 @@ import {
   BookOpen,
   BookOpen,
   ChevronRight,
   ChevronRight,
   CloudOff,
   CloudOff,
+  Cpu,
   Gauge,
   Gauge,
   GitBranch,
   GitBranch,
   Leaf,
   Leaf,
@@ -20,15 +21,29 @@ import {
 import { AppCard } from '@/components/app-card'
 import { AppCard } from '@/components/app-card'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { openSupport } from '@/features/support/Lib/supportVisibility'
 import { openSupport } from '@/features/support/Lib/supportVisibility'
+import { track } from '@/lib/analytics'
 import { useFormatDate } from '@/lib/use-format-date'
 import { useFormatDate } from '@/lib/use-format-date'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
+import { OEM_SOURCE_MAKES } from '../Lib/codes'
+import { makeKey } from '../Lib/makes'
 import { pushRecentLookup } from '../Lib/lookups'
 import { pushRecentLookup } from '../Lib/lookups'
-import type { FaultCodeFlags, FaultCodePageData, ReportState } from '../Lib/types'
+import type {
+  FaultCodeFlags,
+  FaultCodePageData,
+  OemMeaningView,
+  OemPageData,
+  OemSearchHit,
+  ReportState,
+} from '../Lib/types'
 import { DescriptionSections } from './DescriptionSections'
 import { DescriptionSections } from './DescriptionSections'
+import { Likelihood } from './Likelihood'
+import { ResearchCard } from './ResearchCard'
 import { FaultCodeChip, FaultStatusBadge, MeaningSourceLabel } from './FaultCodeChip'
 import { FaultCodeChip, FaultStatusBadge, MeaningSourceLabel } from './FaultCodeChip'
 import { FaultCodesNotice } from './FaultCodesNotice'
 import { FaultCodesNotice } from './FaultCodesNotice'
 import { ReportMeaningPopover } from './ReportMeaningPopover'
 import { ReportMeaningPopover } from './ReportMeaningPopover'
+import { OemWhereUsed } from './OemWhereUsed'
 import { ShareFixCard } from './ShareFixCard'
 import { ShareFixCard } from './ShareFixCard'
+import { WaveformsCard } from './WaveformsCard'
 import { WorkshopFixesCard } from './WorkshopFixesCard'
 import { WorkshopFixesCard } from './WorkshopFixesCard'
 
 
 const LIKELIHOOD_RANK = { high: 0, medium: 1, low: 2 } as const
 const LIKELIHOOD_RANK = { high: 0, medium: 1, low: 2 } as const
@@ -50,6 +65,7 @@ export function FaultCodeDetail({
   onOpenCode,
   onOpenCode,
   layout = 'sheet',
   layout = 'sheet',
   supportEnabled = false,
   supportEnabled = false,
+  oem,
 }: {
 }: {
   data: FaultCodePageData
   data: FaultCodePageData
   /** Opens a related code in place, as the sheet does; links to its page otherwise. */
   /** Opens a related code in place, as the sheet does; links to its page otherwise. */
@@ -57,6 +73,11 @@ export function FaultCodeDetail({
   layout?: 'sheet' | 'page'
   layout?: 'sheet' | 'page'
   /** Whether the support widget is there to receive a question (cloud, switched on). */
   /** Whether the support widget is there to receive a question (cloud, switched on). */
   supportEnabled?: boolean
   supportEnabled?: boolean
+  /**
+   * A make's own code (BMW's 2A82): its make, the meaning shown and how to
+   * switch to another; see FaultCodeOemView, which holds the choice.
+   */
+  oem?: { data: OemPageData; meaning: OemMeaningView | null; onSelect: (id: string) => void }
 }) {
 }) {
   const t = useTranslations('faultCodes')
   const t = useTranslations('faultCodes')
   const { formatDate } = useFormatDate()
   const { formatDate } = useFormatDate()
@@ -65,16 +86,38 @@ export function FaultCodeDetail({
   const [report, setReport] = useState<ReportState | null>(data.report)
   const [report, setReport] = useState<ReportState | null>(data.report)
   const title = detail.title
   const title = detail.title
 
 
+  const oemMake = oem?.data.make
   // Opening a code's page puts it first in this person's recent lookups.
   // Opening a code's page puts it first in this person's recent lookups.
   useEffect(() => {
   useEffect(() => {
-    if (page) pushRecentLookup({ kind: 'code', code: data.code, title: title ?? null })
-  }, [page, data.code, title])
+    if (!page) return
+    if (oemMake)
+      pushRecentLookup({ kind: 'oem', make: oemMake, code: data.code, title: title ?? null })
+    else pushRecentLookup({ kind: 'code', code: data.code, title: title ?? null })
+  }, [page, data.code, title, oemMake])
 
 
-  const askSupport = () =>
+  // Each code opened, and whether we had a meaning for it: the misses say what to add next.
+  const found = Boolean(title)
+  const onJob = Boolean(data.vehicle)
+  useEffect(() => {
+    track('fault_code:page_view', {
+      code: data.code,
+      kind: oemMake ? 'make' : 'obd',
+      make: oemMake ?? null,
+      found,
+      layout: page ? 'page' : 'sheet',
+      on_job: onJob,
+    })
+  }, [data.code, oemMake, found, page, onJob])
+
+  // A make's code is named with its make wherever it leaves this page.
+  const named = oem ? `${oem.data.makeName} ${data.code}` : data.code
+  const askSupport = () => {
+    track('fault_code:support_open', { code: data.code, kind: oem ? 'make' : 'obd' })
     openSupport({
     openSupport({
-      subject: t('support.subject', { code: data.code }),
-      message: t('support.message', { code: data.code, title: title ?? t('noMeaning') }),
+      subject: t('support.subject', { code: named }),
+      message: t('support.message', { code: named, title: title ?? t('noMeaning') }),
     })
     })
+  }
 
 
   const causes = [...detail.causes].sort(
   const causes = [...detail.causes].sort(
     (a, b) => LIKELIHOOD_RANK[a.likelihood] - LIKELIHOOD_RANK[b.likelihood]
     (a, b) => LIKELIHOOD_RANK[a.likelihood] - LIKELIHOOD_RANK[b.likelihood]
@@ -90,16 +133,31 @@ export function FaultCodeDetail({
           size="lg"
           size="lg"
           className={page ? 'px-2.5 py-1 text-lg' : undefined}
           className={page ? 'px-2.5 py-1 text-lg' : undefined}
         />
         />
-        {page && system && (
-          <span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
-            {t(`systems.${system}`)}
-          </span>
+        {oem ? (
+          <>
+            <span className="rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
+              {t('oem.badge', { make: oem.data.makeName })}
+            </span>
+            {data.vehicle && oem.meaning?.matches && (
+              <span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
+                {t('oem.matches')}
+              </span>
+            )}
+          </>
+        ) : (
+          <>
+            {page && system && (
+              <span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
+                {t(`systems.${system}`)}
+              </span>
+            )}
+            <MeaningSourceLabel
+              source={detail.source}
+              make={detail.make}
+              machineTranslated={detail.machineTranslated}
+            />
+          </>
         )}
         )}
-        <MeaningSourceLabel
-          source={detail.source}
-          make={detail.make}
-          machineTranslated={detail.machineTranslated}
-        />
       </div>
       </div>
       <Heading
       <Heading
         className={
         className={
@@ -112,17 +170,23 @@ export function FaultCodeDetail({
           <span className="font-normal text-muted-foreground">
           <span className="font-normal text-muted-foreground">
             {data.connected && !data.available
             {data.connected && !data.available
               ? t('notAvailable')
               ? t('notAvailable')
-              : detail.range === 'manufacturer'
-                ? t('manufacturerUnknown')
-                : t('noMeaning')}
+              : oem
+                ? oem.data.served
+                  ? t('oem.notFound', { code: data.code, make: oem.data.makeName })
+                  : t('oem.notServed', { make: oem.data.makeName })
+                : detail.range === 'manufacturer'
+                  ? t('manufacturerUnknown')
+                  : t('noMeaning')}
           </span>
           </span>
         )}
         )}
       </Heading>
       </Heading>
       {detail.flags && <Flags flags={detail.flags} />}
       {detail.flags && <Flags flags={detail.flags} />}
+      {oem?.meaning && <OemOwnWords meaning={oem.meaning} make={oem.data.makeName} />}
+      {oem && <OemMeanings oem={oem} />}
     </div>
     </div>
   )
   )
 
 
-  const reportButton = data.connected && (
+  const reportButton = data.connected && !oem && (
     <ReportMeaningPopover
     <ReportMeaningPopover
       code={data.code}
       code={data.code}
       version={detail.version}
       version={detail.version}
@@ -146,6 +210,15 @@ export function FaultCodeDetail({
       {data.connected && !data.available && (
       {data.connected && !data.available && (
         <Notice icon={CloudOff}>{t('detail.serviceDown')}</Notice>
         <Notice icon={CloudOff}>{t('detail.serviceDown')}</Notice>
       )}
       )}
+      {!oem && data.makeCodes && data.makeCodes.length > 0 && (
+        <MakeCodes code={data.code} hits={data.makeCodes} vehicleMake={data.vehicle?.make} />
+      )}
+      {oem &&
+        data.vehicle &&
+        oem.data.meanings.some((m) => m.modules.length > 0) &&
+        !oem.data.meanings.some((m) => m.matches) && (
+          <Notice icon={AlertTriangle}>{t('oem.noMatch')}</Notice>
+        )}
     </>
     </>
   )
   )
 
 
@@ -279,24 +352,59 @@ export function FaultCodeDetail({
     </Part>
     </Part>
   )
   )
 
 
+  // Where the make uses it comes from its control units; a code known only from our text has none.
+  const whereUsed = oem?.meaning && oem.meaning.modules.length > 0 && (
+    <Part card={page} icon={Cpu} title={t('oem.whereUsed', { make: oem.data.makeName })}>
+      <OemWhereUsed meaning={oem.meaning} layout={layout} />
+    </Part>
+  )
+
+  // Researching a fix lives on the code's own page, beside the shared fixes.
+  const researchCard = page && data.connected && data.research && (
+    <ResearchCard
+      code={oem ? oem.data.fixCode : data.code}
+      label={named}
+      meaning={oem?.meaning ? { id: oem.meaning.id, title: oem.meaning.title } : null}
+      vehicleId={data.vehicle?.id ?? null}
+      state={data.research}
+    />
+  )
+
   // Shared fixes live on the code's own page, where there is room for them.
   // Shared fixes live on the code's own page, where there is room for them.
   const fixes = page && data.connected && data.fixes
   const fixes = page && data.connected && data.fixes
   const fromOtherWorkshops = fixes && (
   const fromOtherWorkshops = fixes && (
     <WorkshopFixesCard
     <WorkshopFixesCard
-      code={data.code}
+      code={oem ? oem.data.fixCode : data.code}
+      label={named}
+      meaning={oem?.meaning?.id ?? null}
       fixes={data.fixes?.published ?? []}
       fixes={data.fixes?.published ?? []}
       available={data.fixes?.available ?? false}
       available={data.fixes?.available ?? false}
     />
     />
   )
   )
   const shareFix = fixes && (
   const shareFix = fixes && (
     <ShareFixCard
     <ShareFixCard
-      code={data.code}
+      code={oem ? oem.data.fixCode : data.code}
+      label={named}
+      meaning={oem?.meaning ? { id: oem.meaning.id, title: oem.meaning.title } : null}
+      makes={oem ? (OEM_SOURCE_MAKES[oem.data.make] ?? [oem.data.makeName]) : null}
       vehicle={data.vehicle}
       vehicle={data.vehicle}
       own={data.fixes?.own ?? []}
       own={data.fixes?.own ?? []}
       available={data.fixes?.available ?? false}
       available={data.fixes?.available ?? false}
     />
     />
   )
   )
 
 
+  // Scope recordings, known good and faulty, beside the fixes on the code's own page.
+  const waveforms = page && data.connected && data.waveforms && (
+    <WaveformsCard
+      code={oem ? oem.data.fixCode : data.code}
+      label={named}
+      meaning={oem?.meaning ? { id: oem.meaning.id, title: oem.meaning.title } : null}
+      makes={oem ? (OEM_SOURCE_MAKES[oem.data.make] ?? [oem.data.makeName]) : null}
+      vehicle={data.vehicle}
+      state={data.waveforms}
+    />
+  )
+
   if (!page) {
   if (!page) {
     return (
     return (
       <div className="space-y-6">
       <div className="space-y-6">
@@ -307,6 +415,7 @@ export function FaultCodeDetail({
         {notices}
         {notices}
         {meaning}
         {meaning}
         {causeList}
         {causeList}
+        {whereUsed}
         {related}
         {related}
         {jobs}
         {jobs}
       </div>
       </div>
@@ -337,16 +446,19 @@ export function FaultCodeDetail({
           </div>
           </div>
         </div>
         </div>
       </header>
       </header>
-      <FaultCodesNotice />
+      <FaultCodesNotice makes={data.oemMakes} improve={data.connected ? { code: named } : null} />
       {notices}
       {notices}
       <div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_26rem]">
       <div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_26rem]">
         <div className="min-w-0 space-y-4">
         <div className="min-w-0 space-y-4">
           {meaning}
           {meaning}
           {causeList}
           {causeList}
+          {researchCard}
           {fromOtherWorkshops}
           {fromOtherWorkshops}
-          {shareFix}
+          {waveforms}
+          {shareFix && <div id="share-fix">{shareFix}</div>}
         </div>
         </div>
         <div className="min-w-0 space-y-4">
         <div className="min-w-0 space-y-4">
+          {whereUsed}
           {related}
           {related}
           {jobs}
           {jobs}
         </div>
         </div>
@@ -412,23 +524,6 @@ function Notice({
   )
   )
 }
 }
 
 
-/** High, medium or low as a small bar of three, with the word for screen readers. */
-function Likelihood({ level }: { level: 'low' | 'medium' | 'high' }) {
-  const t = useTranslations('faultCodes.detail.likelihood')
-  const filled = level === 'high' ? 3 : level === 'medium' ? 2 : 1
-  return (
-    <span className="mt-1.5 flex shrink-0 items-center gap-0.5" title={t(level)}>
-      {[1, 2, 3].map((i) => (
-        <span
-          key={i}
-          className={cn('h-1.5 w-3 rounded-full', i <= filled ? 'bg-primary' : 'bg-muted')}
-        />
-      ))}
-      <span className="sr-only">{t(level)}</span>
-    </span>
-  )
-}
-
 function Flags({ flags }: { flags: FaultCodeFlags }) {
 function Flags({ flags }: { flags: FaultCodeFlags }) {
   const t = useTranslations('faultCodes.flags')
   const t = useTranslations('faultCodes.flags')
   const items = [
   const items = [
@@ -455,3 +550,123 @@ function Flags({ flags }: { flags: FaultCodeFlags }) {
     </ul>
     </ul>
   )
   )
 }
 }
+
+/**
+ * Makes that give this code a meaning of their own (Ford's P1000), each a link to that
+ * meaning's page; the car's own make first and marked.
+ */
+function MakeCodes({
+  code,
+  hits,
+  vehicleMake,
+}: {
+  code: string
+  hits: OemSearchHit[]
+  vehicleMake?: string | null
+}) {
+  const t = useTranslations('faultCodes.makeCodes')
+  const own = vehicleMake ? makeKey(vehicleMake) : null
+  return (
+    <div className="rounded-lg border border-card-edge bg-card px-4 py-3">
+      <p className="mb-1.5 flex items-center gap-2 text-sm font-medium">
+        <Cpu className="size-4 text-primary" aria-hidden />
+        {t('heading', { code })}
+      </p>
+      <ul className="space-y-1">
+        {hits.map((h) => (
+          <li key={h.make}>
+            <Link
+              href={`/fault-codes/oem/${h.make}/${h.code}`}
+              onClick={() =>
+                track('fault_code:make_code_open', {
+                  code: h.code,
+                  make: h.make,
+                  own_make: h.make === own,
+                })
+              }
+              className="group flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm"
+            >
+              <span className="font-medium">{h.makeName}</span>
+              <span className="text-muted-foreground group-hover:text-foreground group-hover:underline">
+                {h.title}
+              </span>
+              {h.make === own && (
+                <span className="rounded-full border border-primary/30 bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary">
+                  {t('thisCar')}
+                </span>
+              )}
+            </Link>
+          </li>
+        ))}
+      </ul>
+    </div>
+  )
+}
+
+/** The make's own words under our title, or its English under its own text. */
+function OemOwnWords({ meaning, make }: { meaning: OemMeaningView; make: string }) {
+  const t = useTranslations('faultCodes.oem')
+  // A code known only from our text has no words of the make's own.
+  if (!meaning.text) return null
+  if (meaning.ownTitle)
+    return (
+      <p className="text-sm text-muted-foreground" lang={meaning.textLang}>
+        {t('bmwText', { text: meaning.text, make })}
+      </p>
+    )
+  if (meaning.textEn && meaning.textLang !== 'en')
+    return (
+      <p className="text-sm text-muted-foreground" lang="en">
+        {t('inEnglish', { text: meaning.textEn, make })}
+      </p>
+    )
+  return null
+}
+
+/**
+ * The meanings a make's code has, one chosen: BMW's 2A82 is the inlet VANOS on
+ * one engine computer and a lambda fault on another. The one fitting the car
+ * comes first and is marked.
+ */
+function OemMeanings({
+  oem,
+}: {
+  oem: { data: OemPageData; meaning: OemMeaningView | null; onSelect: (id: string) => void }
+}) {
+  const t = useTranslations('faultCodes.oem')
+  const { meanings } = oem.data
+  if (meanings.length < 2) return null
+  return (
+    <div className="space-y-2 pt-1">
+      <p className="text-xs text-muted-foreground">{t('summary', { count: meanings.length })}</p>
+      <div className="flex flex-wrap gap-1.5" role="group" aria-label={t('meanings')}>
+        {meanings.map((m) => {
+          const on = m.id === oem.meaning?.id
+          return (
+            <button
+              key={m.id}
+              type="button"
+              aria-pressed={on}
+              onClick={() => oem.onSelect(m.id)}
+              className={cn(
+                'inline-flex max-w-full cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1 text-left text-xs transition-colors',
+                on
+                  ? 'border-primary/50 bg-primary/10 font-medium text-foreground'
+                  : 'border-card-edge bg-background/60 text-muted-foreground hover:border-primary/30 hover:text-foreground'
+              )}
+            >
+              <span className="truncate" lang={m.titleLang}>
+                {m.title}
+              </span>
+              {m.matches && (
+                <span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[10px] font-medium text-primary">
+                  {t('fitsThisCar')}
+                </span>
+              )}
+            </button>
+          )
+        })}
+      </div>
+    </div>
+  )
+}

+ 9 - 3
src/features/fault-codes/Components/FaultCodeEntryDialog.tsx

@@ -62,12 +62,15 @@ export function FaultCodeEntryDialog({
   serviceRecordId,
   serviceRecordId,
   vehicleId,
   vehicleId,
   concerns = [],
   concerns = [],
+  oemSource = null,
 }: {
 }: {
   open: boolean
   open: boolean
   onOpenChange: (open: boolean) => void
   onOpenChange: (open: boolean) => void
   serviceRecordId: string
   serviceRecordId: string
   vehicleId: string
   vehicleId: string
   concerns?: { id: string; description: string }[]
   concerns?: { id: string; description: string }[]
+  /** The car has manufacturer code data ("bmw"), so BMW codes are read too. */
+  oemSource?: string | null
 }) {
 }) {
   const t = useTranslations('faultCodes.entry')
   const t = useTranslations('faultCodes.entry')
   const router = useRouter()
   const router = useRouter()
@@ -87,7 +90,10 @@ export function FaultCodeEntryDialog({
     setConcernId('none')
     setConcernId('none')
   }, [open])
   }, [open])
 
 
-  const found = useMemo(() => extractFaultCodes(text), [text])
+  const found = useMemo(
+    () => extractFaultCodes(text, { oem: oemSource !== null }),
+    [text, oemSource]
+  )
   const codes = found.filter((c) => !removed.has(c.code))
   const codes = found.filter((c) => !removed.has(c.code))
 
 
   // Ask once per code, for the codes not asked about yet.
   // Ask once per code, for the codes not asked about yet.
@@ -155,7 +161,7 @@ export function FaultCodeEntryDialog({
       <DialogContent className="sm:max-w-lg">
       <DialogContent className="sm:max-w-lg">
         <DialogHeader>
         <DialogHeader>
           <DialogTitle>{t('title')}</DialogTitle>
           <DialogTitle>{t('title')}</DialogTitle>
-          <DialogDescription>{t('description')}</DialogDescription>
+          <DialogDescription>{t(oemSource ? 'descriptionOem' : 'description')}</DialogDescription>
         </DialogHeader>
         </DialogHeader>
         <DialogBody className="min-w-0 space-y-4">
         <DialogBody className="min-w-0 space-y-4">
           <div className="space-y-2">
           <div className="space-y-2">
@@ -167,7 +173,7 @@ export function FaultCodeEntryDialog({
               autoFocus
               autoFocus
               value={text}
               value={text}
               onChange={(e) => setText(e.target.value)}
               onChange={(e) => setText(e.target.value)}
-              placeholder={t('placeholder')}
+              placeholder={t(oemSource ? 'placeholderOem' : 'placeholder')}
               rows={3}
               rows={3}
               className="font-mono"
               className="font-mono"
               spellCheck={false}
               spellCheck={false}

+ 187 - 41
src/features/fault-codes/Components/FaultCodeLookup.tsx

@@ -7,6 +7,7 @@ import { useLocale, useTranslations } from 'next-intl'
 import {
 import {
   ChevronRight,
   ChevronRight,
   CloudOff,
   CloudOff,
+  FileUp,
   History,
   History,
   LifeBuoy,
   LifeBuoy,
   Loader2,
   Loader2,
@@ -18,10 +19,13 @@ import { AppCard } from '@/components/app-card'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { openSupport } from '@/features/support/Lib/supportVisibility'
 import { openSupport } from '@/features/support/Lib/supportVisibility'
+import { track } from '@/lib/analytics'
 import { useFormatDate } from '@/lib/use-format-date'
 import { useFormatDate } from '@/lib/use-format-date'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { suggestFaultCodes } from '../Actions/suggestFaultCodes'
 import { suggestFaultCodes } from '../Actions/suggestFaultCodes'
-import { normalizeFaultCode } from '../Lib/codes'
+import type { OemSearchHit } from '../Lib/types'
+import { normalizeFaultCode, normalizeOemCode } from '../Lib/codes'
+import { makeName } from '../Lib/makes'
 import {
 import {
   clearRecentLookups,
   clearRecentLookups,
   pushRecentLookup,
   pushRecentLookup,
@@ -30,6 +34,7 @@ import {
 } from '../Lib/lookups'
 } from '../Lib/lookups'
 import { FaultCodeChip } from './FaultCodeChip'
 import { FaultCodeChip } from './FaultCodeChip'
 import { FaultCodesNotice } from './FaultCodesNotice'
 import { FaultCodesNotice } from './FaultCodesNotice'
+import { ImportCodesDialog } from './ImportCodesDialog'
 
 
 export interface FaultCodeOverview {
 export interface FaultCodeOverview {
   connected: boolean
   connected: boolean
@@ -37,10 +42,19 @@ export interface FaultCodeOverview {
   query: string
   query: string
   available: boolean
   available: boolean
   hits: { code: string; title: string }[]
   hits: { code: string; title: string }[]
+  /** A make's own code matching what was typed (BMW's 283700). */
+  oemHits: OemSearchHit[]
+  /** The makes whose own codes the service serves ("bmw"). */
+  oemMakes: string[]
   recent: { code: string; meaning: string | null; at: string; count: number }[]
   recent: { code: string; meaning: string | null; at: string; count: number }[]
 }
 }
 
 
-type Results = { query: string; available: boolean; hits: { code: string; title: string }[] }
+type Results = {
+  query: string
+  available: boolean
+  hits: { code: string; title: string }[]
+  oemHits: OemSearchHit[]
+}
 
 
 /** Codes to try that every workshop knows. */
 /** Codes to try that every workshop knows. */
 const EXAMPLE_CODES = ['P0420', 'P0300', 'P0171']
 const EXAMPLE_CODES = ['P0420', 'P0300', 'P0171']
@@ -69,10 +83,16 @@ export function FaultCodeLookup({
   const [value, setValue] = useState(overview.query)
   const [value, setValue] = useState(overview.query)
   const [results, setResults] = useState<Results | null>(
   const [results, setResults] = useState<Results | null>(
     overview.query
     overview.query
-      ? { query: overview.query, available: overview.available, hits: overview.hits }
+      ? {
+          query: overview.query,
+          available: overview.available,
+          hits: overview.hits,
+          oemHits: overview.oemHits,
+        }
       : null
       : null
   )
   )
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
+  const [importing, setImporting] = useState(false)
   const latest = useRef(overview.query)
   const latest = useRef(overview.query)
   const [recent, setRecent] = useState<RecentLookup[]>([])
   const [recent, setRecent] = useState<RecentLookup[]>([])
   const [now, setNow] = useState(0)
   const [now, setNow] = useState(0)
@@ -99,7 +119,12 @@ export function FaultCodeLookup({
       return
       return
     }
     }
     if (text === overview.query) {
     if (text === overview.query) {
-      setResults({ query: overview.query, available: overview.available, hits: overview.hits })
+      setResults({
+        query: overview.query,
+        available: overview.available,
+        hits: overview.hits,
+        oemHits: overview.oemHits,
+      })
       setLoading(false)
       setLoading(false)
       return
       return
     }
     }
@@ -111,16 +136,43 @@ export function FaultCodeLookup({
       if (reply.success && reply.data) setResults(reply.data)
       if (reply.success && reply.data) setResults(reply.data)
     }, DEBOUNCE_MS)
     }, DEBOUNCE_MS)
     return () => clearTimeout(timer)
     return () => clearTimeout(timer)
-  }, [value, overview.connected, overview.query, overview.available, overview.hits])
+  }, [
+    value,
+    overview.connected,
+    overview.query,
+    overview.available,
+    overview.hits,
+    overview.oemHits,
+  ])
 
 
   const go = (raw: string) => {
   const go = (raw: string) => {
     const text = raw.trim()
     const text = raw.trim()
     if (!text) return
     if (!text) return
     const code = normalizeFaultCode(text)
     const code = normalizeFaultCode(text)
     if (code) {
     if (code) {
+      track('fault_code:search', { kind: 'code' })
       router.push(`/fault-codes/${code}`)
       router.push(`/fault-codes/${code}`)
       return
       return
     }
     }
+    // A make's own code (BMW's 283700): straight to it when the results show one make with it,
+    // else the server finds it, opening it when one make has it and listing them otherwise.
+    const oem = normalizeOemCode(text)
+    if (oem && oem.length >= 4 && overview.oemMakes.length > 0) {
+      track('fault_code:search', { kind: 'make_code' })
+      const makes = new Set(
+        [...(results?.oemHits ?? []), ...overview.oemHits]
+          .filter((hit) => hit.code === oem)
+          .map((hit) => hit.make)
+      )
+      const [only] = makes
+      router.push(
+        makes.size === 1
+          ? `/fault-codes/oem/${only}/${oem}`
+          : `/fault-codes?q=${encodeURIComponent(oem)}`
+      )
+      return
+    }
+    track('fault_code:search', { kind: 'words' })
     setRecent(pushRecentLookup({ kind: 'words', query: text }))
     setRecent(pushRecentLookup({ kind: 'words', query: text }))
     router.push(`/fault-codes?q=${encodeURIComponent(text)}`)
     router.push(`/fault-codes?q=${encodeURIComponent(text)}`)
   }
   }
@@ -149,6 +201,24 @@ export function FaultCodeLookup({
               </div>
               </div>
             </div>
             </div>
             <div className="flex shrink-0 flex-wrap items-center gap-2">
             <div className="flex shrink-0 flex-wrap items-center gap-2">
+              {overview.connected && (
+                <>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="xs"
+                    className="gap-1.5"
+                    onClick={() => {
+                      track('fault_code:import_open')
+                      setImporting(true)
+                    }}
+                  >
+                    <FileUp className="size-3.5" />
+                    {tc('import.button')}
+                  </Button>
+                  <ImportCodesDialog open={importing} onOpenChange={setImporting} />
+                </>
+              )}
               {supportEnabled && (
               {supportEnabled && (
                 <Button
                 <Button
                   type="button"
                   type="button"
@@ -164,7 +234,11 @@ export function FaultCodeLookup({
             </div>
             </div>
           </div>
           </div>
 
 
-          <FaultCodesNotice className="bg-background/60" />
+          <FaultCodesNotice
+            className="bg-background/60"
+            makes={overview.oemMakes}
+            improve={overview.connected ? {} : null}
+          />
 
 
           <form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row" role="search">
           <form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row" role="search">
             <label htmlFor="fault-code-lookup" className="sr-only">
             <label htmlFor="fault-code-lookup" className="sr-only">
@@ -227,39 +301,56 @@ export function FaultCodeLookup({
       )}
       )}
       {!overview.connectable && <p className="text-sm text-muted-foreground">{t('cloudOnly')}</p>}
       {!overview.connectable && <p className="text-sm text-muted-foreground">{t('cloudOnly')}</p>}
 
 
-      {results && (
-        <AppCard
-          icon={Search}
-          title={t('results', { query: results.query })}
-          badge={results.available && results.hits.length > 0 ? results.hits.length : undefined}
-          contentClassName={results.available && results.hits.length > 0 ? 'p-0' : undefined}
-          className={cn('transition-opacity', loading && 'opacity-60')}
-        >
-          {!results.available ? (
-            <p className="text-sm text-muted-foreground">{tc('notAvailable')}</p>
-          ) : results.hits.length === 0 ? (
-            <p className="text-sm text-muted-foreground">
-              {t('noResults', { query: results.query })}
-            </p>
-          ) : (
-            <ul className="grid md:grid-cols-2">
-              {results.hits.map((hit, i) => (
-                <li
-                  key={hit.code}
-                  className={cn(
-                    'border-card-edge/60',
-                    i > 0 && 'border-t',
-                    i === 1 && 'md:border-t-0',
-                    i % 2 === 0 && 'md:border-r'
-                  )}
-                >
-                  <CodeRow code={hit.code} title={hit.title} />
-                </li>
-              ))}
-            </ul>
-          )}
-        </AppCard>
-      )}
+      {results &&
+        (() => {
+          // A make's own code first: typed in full, it is what was meant.
+          const rows = [
+            ...(results.oemHits ?? []).map((hit) => ({ key: `${hit.make}:${hit.code}`, oem: hit })),
+            ...(results.available ? results.hits.map((hit) => ({ key: hit.code, obd: hit })) : []),
+          ] as { key: string; oem?: OemSearchHit; obd?: { code: string; title: string } }[]
+          return (
+            <AppCard
+              icon={Search}
+              title={t('results', { query: results.query })}
+              badge={rows.length > 0 ? rows.length : undefined}
+              contentClassName={rows.length > 0 ? 'p-0' : undefined}
+              className={cn('transition-opacity', loading && 'opacity-60')}
+            >
+              {rows.length === 0 ? (
+                <p className="text-sm text-muted-foreground">
+                  {results.available
+                    ? t('noResults', { query: results.query })
+                    : tc('notAvailable')}
+                </p>
+              ) : (
+                <ul className="grid md:grid-cols-2">
+                  {rows.map((row, i) => (
+                    <li
+                      key={row.key}
+                      className={cn(
+                        'border-card-edge/60',
+                        i > 0 && 'border-t',
+                        i === 1 && 'md:border-t-0',
+                        i % 2 === 0 && 'md:border-r'
+                      )}
+                    >
+                      {row.oem ? (
+                        <OemRow
+                          make={row.oem.make}
+                          code={row.oem.code}
+                          title={row.oem.title}
+                          meanings={row.oem.meanings}
+                        />
+                      ) : row.obd ? (
+                        <CodeRow code={row.obd.code} title={row.obd.title} />
+                      ) : null}
+                    </li>
+                  ))}
+                </ul>
+              )}
+            </AppCard>
+          )
+        })()}
 
 
       <div className="grid items-start gap-4 lg:grid-cols-2">
       <div className="grid items-start gap-4 lg:grid-cols-2">
         <AppCard
         <AppCard
@@ -288,8 +379,23 @@ export function FaultCodeLookup({
           ) : (
           ) : (
             <ul className="divide-y divide-card-edge/60">
             <ul className="divide-y divide-card-edge/60">
               {recent.map((r) => (
               {recent.map((r) => (
-                <li key={r.kind === 'code' ? `c:${r.code}` : `w:${r.query}`}>
-                  {r.kind === 'code' ? (
+                <li
+                  key={
+                    r.kind === 'code'
+                      ? `c:${r.code}`
+                      : r.kind === 'oem'
+                        ? `o:${r.make}:${r.code}`
+                        : `w:${r.query}`
+                  }
+                >
+                  {r.kind === 'oem' ? (
+                    <OemRow
+                      make={r.make}
+                      code={r.code}
+                      title={r.title}
+                      meta={now ? timeAgo(r.at, now, locale) : undefined}
+                    />
+                  ) : r.kind === 'code' ? (
                     <CodeRow
                     <CodeRow
                       code={r.code}
                       code={r.code}
                       title={r.title}
                       title={r.title}
@@ -380,3 +486,43 @@ function CodeRow({ code, title, meta }: { code: string; title: string | null; me
     </Link>
     </Link>
   )
   )
 }
 }
+
+/** A make's own code: its chip, the make, the meaning to name it by, opening its page. */
+function OemRow({
+  make,
+  code,
+  title,
+  meanings = 1,
+  meta,
+}: {
+  make: string
+  code: string
+  title: string | null
+  meanings?: number
+  meta?: string
+}) {
+  const t = useTranslations('faultCodes.oem')
+  return (
+    <Link
+      href={`/fault-codes/oem/${make}/${code}`}
+      className="group flex items-center gap-3 px-5 py-2.5 transition-colors hover:bg-muted/50"
+    >
+      <FaultCodeChip code={code} />
+      <span className="shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary">
+        {t('badge', { make: makeName(make) })}
+      </span>
+      <span className="min-w-0 flex-1 truncate text-sm">
+        {title ?? <span className="text-muted-foreground">{code}</span>}
+        {meanings > 1 && (
+          <span className="ml-1.5 text-xs text-muted-foreground">
+            {t('otherMeanings', { count: meanings - 1 })}
+          </span>
+        )}
+      </span>
+      {meta && (
+        <span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">{meta}</span>
+      )}
+      <ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
+    </Link>
+  )
+}

+ 40 - 0
src/features/fault-codes/Components/FaultCodeOemView.tsx

@@ -0,0 +1,40 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { oemDetail } from '../Lib/oem'
+import type { FaultCodePageData, OemPageData } from '../Lib/types'
+import { FaultCodeDetail } from './FaultCodeDetail'
+
+export type OemCodePageData = FaultCodePageData & { oem: OemPageData }
+
+/**
+ * A make's own code (BMW's 2A82) on the same page and sheet as every other
+ * code: the meaning, likely causes, shared fixes and the jobs here, plus
+ * where the make uses it. A code with several meanings shows one at a time,
+ * the one fitting the job's car first, and switches in place.
+ */
+export function FaultCodeOemView({
+  data,
+  layout = 'sheet',
+  supportEnabled = false,
+}: {
+  data: OemCodePageData
+  layout?: 'sheet' | 'page'
+  supportEnabled?: boolean
+}) {
+  const { meanings } = data.oem
+  const [selected, setSelected] = useState(meanings[0]?.id ?? '')
+  const meaning = meanings.find((m) => m.id === selected) ?? meanings[0] ?? null
+  const detail = useMemo(
+    () => oemDetail(data.code, data.oem.makeName, meaning),
+    [data.code, data.oem.makeName, meaning]
+  )
+  return (
+    <FaultCodeDetail
+      data={{ ...data, detail }}
+      layout={layout}
+      supportEnabled={supportEnabled}
+      oem={{ data: data.oem, meaning, onSelect: setSelected }}
+    />
+  )
+}

+ 35 - 8
src/features/fault-codes/Components/FaultCodeSheet.tsx

@@ -13,6 +13,9 @@ import {
   SheetTitle,
   SheetTitle,
 } from '@/components/ui/sheet'
 } from '@/components/ui/sheet'
 import { getFaultCodeDetail } from '../Actions/getFaultCodeDetail'
 import { getFaultCodeDetail } from '../Actions/getFaultCodeDetail'
+import { getOemFaultCode } from '../Actions/getOemFaultCode'
+import { isFaultCode, isOemCode } from '../Lib/codes'
+import { FaultCodeOemView, type OemCodePageData } from './FaultCodeOemView'
 import type { FaultCodePageData } from '../Lib/types'
 import type { FaultCodePageData } from '../Lib/types'
 import { FaultCodeDetail } from './FaultCodeDetail'
 import { FaultCodeDetail } from './FaultCodeDetail'
 
 
@@ -24,16 +27,21 @@ import { FaultCodeDetail } from './FaultCodeDetail'
 export function FaultCodeSheet({
 export function FaultCodeSheet({
   code,
   code,
   vehicleId,
   vehicleId,
+  oemSource = null,
   onOpenChange,
   onOpenChange,
 }: {
 }: {
   /** The code to show; null keeps the sheet closed. */
   /** The code to show; null keeps the sheet closed. */
   code: string | null
   code: string | null
   vehicleId?: string | null
   vehicleId?: string | null
+  /** The car's manufacturer data ("bmw"): its own codes open the manufacturer view. */
+  oemSource?: string | null
   onOpenChange: (open: boolean) => void
   onOpenChange: (open: boolean) => void
 }) {
 }) {
   const t = useTranslations('faultCodes')
   const t = useTranslations('faultCodes')
   const [shown, setShown] = useState<string | null>(code)
   const [shown, setShown] = useState<string | null>(code)
   const [data, setData] = useState<FaultCodePageData | null>(null)
   const [data, setData] = useState<FaultCodePageData | null>(null)
+  const [oem, setOem] = useState<OemCodePageData | null>(null)
+  const oemCode = oemSource && shown && !isFaultCode(shown) && isOemCode(shown) ? shown : null
   const [failed, setFailed] = useState(false)
   const [failed, setFailed] = useState(false)
 
 
   useEffect(() => {
   useEffect(() => {
@@ -44,16 +52,27 @@ export function FaultCodeSheet({
     if (!shown) return
     if (!shown) return
     let cancelled = false
     let cancelled = false
     setData(null)
     setData(null)
+    setOem(null)
     setFailed(false)
     setFailed(false)
-    getFaultCodeDetail({ code: shown, vehicleId: vehicleId ?? null }).then((result) => {
-      if (cancelled) return
-      if (result.success && result.data) setData(result.data)
-      else setFailed(true)
-    })
+    if (oemCode && oemSource === 'bmw') {
+      getOemFaultCode({ source: oemSource, code: oemCode, vehicleId: vehicleId ?? null }).then(
+        (result) => {
+          if (cancelled) return
+          if (result.success && result.data) setOem(result.data)
+          else setFailed(true)
+        }
+      )
+    } else {
+      getFaultCodeDetail({ code: shown, vehicleId: vehicleId ?? null }).then((result) => {
+        if (cancelled) return
+        if (result.success && result.data) setData(result.data)
+        else setFailed(true)
+      })
+    }
     return () => {
     return () => {
       cancelled = true
       cancelled = true
     }
     }
-  }, [shown, vehicleId])
+  }, [shown, vehicleId, oemCode, oemSource])
 
 
   return (
   return (
     <Sheet open={code !== null} onOpenChange={onOpenChange}>
     <Sheet open={code !== null} onOpenChange={onOpenChange}>
@@ -63,7 +82,9 @@ export function FaultCodeSheet({
           <SheetDescription className="sr-only">{t('sheet.description')}</SheetDescription>
           <SheetDescription className="sr-only">{t('sheet.description')}</SheetDescription>
         </SheetHeader>
         </SheetHeader>
         <div className="p-4">
         <div className="p-4">
-          {data && data.code === shown ? (
+          {oem && oem.code === shown ? (
+            <FaultCodeOemView data={oem} />
+          ) : data && data.code === shown ? (
             <FaultCodeDetail key={data.code} data={data} onOpenCode={setShown} />
             <FaultCodeDetail key={data.code} data={data} onOpenCode={setShown} />
           ) : failed ? (
           ) : failed ? (
             <p className="text-sm text-muted-foreground">{t('sheet.loadFailed')}</p>
             <p className="text-sm text-muted-foreground">{t('sheet.loadFailed')}</p>
@@ -77,7 +98,13 @@ export function FaultCodeSheet({
         {shown && (
         {shown && (
           <div className="mt-auto border-t p-4">
           <div className="mt-auto border-t p-4">
             <Button asChild variant="outline" size="sm">
             <Button asChild variant="outline" size="sm">
-              <Link href={`/fault-codes/${shown}${vehicleId ? `?vehicle=${vehicleId}` : ''}`}>
+              <Link
+                href={`${
+                  oem && oem.code === shown
+                    ? `/fault-codes/oem/${oem.oem.make}/${shown}`
+                    : `/fault-codes/${shown}`
+                }${vehicleId ? `?vehicle=${vehicleId}` : ''}`}
+              >
                 <ExternalLink />
                 <ExternalLink />
                 {t('sheet.openPage')}
                 {t('sheet.openPage')}
               </Link>
               </Link>

+ 53 - 9
src/features/fault-codes/Components/FaultCodesNotice.tsx

@@ -1,32 +1,76 @@
 'use client'
 'use client'
 
 
-import { useTranslations } from 'next-intl'
-import { Sparkles } from 'lucide-react'
+import { useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import { MessageSquarePlus, Sparkles } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { track } from '@/lib/analytics'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
+import { makeName } from '../Lib/makes'
+import { HelpImproveDialog } from './HelpImproveDialog'
 
 
 /**
 /**
  * Says fault codes are new and still growing, so a code that is not here yet
  * Says fault codes are new and still growing, so a code that is not here yet
- * reads as "coming" rather than "broken", and that only the standard OBD
- * codes are covered for now. Visible text rather than a tooltip, because a
- * tooltip never opens on a phone.
+ * reads as "coming" rather than "broken", and which codes are covered: the
+ * standard OBD codes, and BMW's own where the service serves them. Visible text rather than
+ * a tooltip, because a tooltip never opens on a phone. "Help us improve" beside it lets a
+ * workshop tell us where to find what is missing.
  */
  */
-export function FaultCodesNotice({ className }: { className?: string }) {
+export function FaultCodesNotice({
+  className,
+  makes = [],
+  improve = null,
+}: {
+  className?: string
+  /** The makes whose own codes the service serves ("bmw"), so the notice says so. */
+  makes?: string[]
+  /** "Help us improve", where the workshop reaches Torqvoice; with the code on screen, if any. */
+  improve?: { code?: string | null } | null
+}) {
   const t = useTranslations('faultCodes.notice')
   const t = useTranslations('faultCodes.notice')
+  const ti = useTranslations('faultCodes.improve')
+  const [open, setOpen] = useState(false)
+  const locale = useLocale()
+  const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' })
   return (
   return (
     <div
     <div
       role="note"
       role="note"
       className={cn(
       className={cn(
-        'flex items-start gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3',
+        'flex flex-wrap items-start gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3 sm:flex-nowrap',
         className
         className
       )}
       )}
     >
     >
       <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
       <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
         <Sparkles className="size-3.5" aria-hidden />
         <Sparkles className="size-3.5" aria-hidden />
       </span>
       </span>
-      <div className="min-w-0 space-y-0.5 text-sm">
+      <div className="min-w-0 flex-1 space-y-0.5 text-sm">
         <p className="font-medium">{t('title')}</p>
         <p className="font-medium">{t('title')}</p>
-        <p className="text-muted-foreground">{t('body')}</p>
+        <p className="text-muted-foreground">
+          {makes.length === 0
+            ? t('body')
+            : makes.length === 1 && makes[0] === 'bmw'
+              ? t('bodyBmw')
+              : t('bodyMakes', { makes: list.format(makes.map(makeName)) })}
+        </p>
       </div>
       </div>
+      {improve && (
+        <>
+          <Button
+            type="button"
+            variant="outline"
+            size="xs"
+            className="ml-10 shrink-0 gap-1.5 bg-background sm:ml-0"
+            onClick={() => {
+              track('fault_code:improve_open', { with_code: Boolean(improve.code) })
+              setOpen(true)
+            }}
+          >
+            <MessageSquarePlus className="size-3.5" />
+            {ti('button')}
+          </Button>
+          <HelpImproveDialog open={open} onOpenChange={setOpen} code={improve.code ?? null} />
+        </>
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 7 - 0
src/features/fault-codes/Components/FaultCodesOnJob.tsx

@@ -20,6 +20,11 @@ export interface FaultCodesJobInfo {
   connected: boolean
   connected: boolean
   /** Code to when it was last read on this vehicle in another job (ISO). */
   /** Code to when it was last read on this vehicle in another job (ISO). */
   seenBefore: Record<string, string>
   seenBefore: Record<string, string>
+  /**
+   * The manufacturer data the car's own codes are looked up in ("bmw" for a
+   * BMW, MINI or Rolls-Royce), so BMW codes can be typed in; null otherwise.
+   */
+  oemSource?: string | null
 }
 }
 
 
 interface JobFaultCodes extends FaultCodesJobInfo {
 interface JobFaultCodes extends FaultCodesJobInfo {
@@ -77,11 +82,13 @@ export function FaultCodesJobProvider({
           serviceRecordId={serviceRecordId}
           serviceRecordId={serviceRecordId}
           vehicleId={vehicleId}
           vehicleId={vehicleId}
           concerns={concerns}
           concerns={concerns}
+          oemSource={info.oemSource ?? null}
         />
         />
       )}
       )}
       <FaultCodeSheet
       <FaultCodeSheet
         code={sheetCode}
         code={sheetCode}
         vehicleId={vehicleId}
         vehicleId={vehicleId}
+        oemSource={info.oemSource ?? null}
         onOpenChange={(open) => !open && setSheetCode(null)}
         onOpenChange={(open) => !open && setSheetCode(null)}
       />
       />
     </Ctx.Provider>
     </Ctx.Provider>

+ 9 - 2
src/features/fault-codes/Components/FixPhotos.tsx

@@ -3,7 +3,7 @@
 import { useState } from 'react'
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { MediaLightbox } from '@/features/inspections/Components/MediaLightbox'
 import { MediaLightbox } from '@/features/inspections/Components/MediaLightbox'
-import type { FixPhoto, SharedFix } from '../Lib/types'
+import type { FixPhoto } from '../Lib/types'
 
 
 /** Where the app serves a shared fix's photo from the code service. */
 /** Where the app serves a shared fix's photo from the code service. */
 export function fixPhotoUrl(id: string, n: number, size: 'full' | 'thumb' = 'full'): string {
 export function fixPhotoUrl(id: string, n: number, size: 'full' | 'thumb' = 'full'): string {
@@ -12,7 +12,14 @@ export function fixPhotoUrl(id: string, n: number, size: 'full' | 'thumb' = 'ful
 }
 }
 
 
 /** "2014 Volkswagen Golf · CAXA", or null when the fix names no vehicle. */
 /** "2014 Volkswagen Golf · CAXA", or null when the fix names no vehicle. */
-export function fixVehicleLine(vehicle: SharedFix['vehicle']): string | null {
+export function fixVehicleLine(
+  vehicle: {
+    make?: string | null
+    model?: string | null
+    year?: number | null
+    engine?: string | null
+  } | null
+): string | null {
   if (!vehicle) return null
   if (!vehicle) return null
   const name = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
   const name = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
   const line = [name, vehicle.engine].filter(Boolean).join(' · ')
   const line = [name, vehicle.engine].filter(Boolean).join(' · ')

+ 201 - 0
src/features/fault-codes/Components/HelpImproveDialog.tsx

@@ -0,0 +1,201 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Link2, Loader2, Send, X } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { track } from '@/lib/analytics'
+import { cn } from '@/lib/utils'
+import { sendFaultCodeFeedback } from '../Actions/sendFaultCodeFeedback'
+import { IMPROVE_TOPICS, MAX_IMPROVE_TEXT } from '../Schema/faultCodeSchema'
+
+type Topic = (typeof IMPROVE_TOPICS)[number]
+
+/** The sites the pasted links point to, each once, so the sender sees they were understood. */
+function linkHosts(text: string): string[] {
+  const hosts = new Set<string>()
+  for (const match of text.matchAll(/https?:\/\/[^\s<>"')\]]+/gi)) {
+    try {
+      hosts.add(new URL(match[0]).hostname.replace(/^www\./, ''))
+    } catch {
+      // Not a link after all.
+    }
+  }
+  return [...hosts]
+}
+
+/**
+ * "Help us improve" for fault codes: where a make's codes are published, a meaning that is
+ * wrong, a link worth reading, or an idea, written or pasted in a few words and mailed to the
+ * Torqvoice team. A topic is a tap and optional; the code on screen goes with it unless taken
+ * off. What was written stays when the dialog is closed, until it is sent.
+ */
+export function HelpImproveDialog({
+  open,
+  onOpenChange,
+  code = null,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  /** The code on screen as people read it ("BMW 2A82"); null on the lookup page. */
+  code?: string | null
+}) {
+  const t = useTranslations('faultCodes.improve')
+  const [topic, setTopic] = useState<Topic | null>(null)
+  const [message, setMessage] = useState('')
+  const [withCode, setWithCode] = useState(true)
+  const [sending, setSending] = useState(false)
+  const hosts = linkHosts(message)
+  const tooLong = message.length > MAX_IMPROVE_TEXT
+
+  const send = async () => {
+    if (!message.trim() || tooLong || sending) return
+    setSending(true)
+    const result = await sendFaultCodeFeedback({
+      topic: topic ?? undefined,
+      message: message.trim(),
+      code: withCode && code ? code : undefined,
+      page: window.location.pathname,
+    }).catch(() => null)
+    setSending(false)
+    if (result?.success && result.data?.sent) {
+      track('fault_code:improve_send', {
+        topic: topic ?? null,
+        with_code: Boolean(withCode && code),
+        links: hosts.length,
+      })
+      toast.success(t('sent'))
+      setTopic(null)
+      setMessage('')
+      setWithCode(true)
+      onOpenChange(false)
+      return
+    }
+    // What was written stays, ready to try again.
+    if (result?.success && result.data?.reason === 'limited') toast.error(t('errors.limited'))
+    else toast.error(t('errors.failed'))
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={(next) => !sending && onOpenChange(next)}>
+      <DialogContent className="sm:max-w-xl">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+
+        <DialogBody className="space-y-5">
+          <div className="space-y-2">
+            <span className="text-sm font-medium">{t('topic')}</span>
+            <div className="flex flex-wrap gap-1.5" role="group" aria-label={t('topic')}>
+              {IMPROVE_TOPICS.map((k) => (
+                <button
+                  key={k}
+                  type="button"
+                  aria-pressed={topic === k}
+                  onClick={() => setTopic((now) => (now === k ? null : k))}
+                  className={cn(
+                    'rounded-md border px-3 py-1.5 text-sm transition-colors',
+                    topic === k
+                      ? 'border-primary bg-primary text-primary-foreground'
+                      : 'border-border hover:border-primary/40 hover:bg-muted/40'
+                  )}
+                >
+                  {t(`topics.${k}`)}
+                </button>
+              ))}
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <div className="flex items-baseline justify-between gap-3">
+              <Label htmlFor="improve-message" className="text-sm font-medium">
+                {t('message')}
+              </Label>
+              {message.length > MAX_IMPROVE_TEXT * 0.8 && (
+                <span
+                  className={cn(
+                    'text-xs tabular-nums text-muted-foreground',
+                    tooLong && 'font-medium text-destructive'
+                  )}
+                >
+                  {message.length} / {MAX_IMPROVE_TEXT}
+                </span>
+              )}
+            </div>
+            <Textarea
+              id="improve-message"
+              value={message}
+              rows={6}
+              placeholder={t(`placeholders.${topic ?? 'idea'}`)}
+              onChange={(e) => setMessage(e.target.value)}
+            />
+            {hosts.length > 0 && (
+              <ul className="flex flex-wrap items-center gap-1.5" aria-label={t('links')}>
+                {hosts.map((host) => (
+                  <li
+                    key={host}
+                    className="inline-flex items-center gap-1 rounded-full border border-card-edge bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground"
+                  >
+                    <Link2 className="size-3" aria-hidden />
+                    {host}
+                  </li>
+                ))}
+              </ul>
+            )}
+          </div>
+
+          {code && withCode && (
+            <div className="flex items-center justify-between gap-3 rounded-lg border border-card-edge bg-muted/30 px-3 py-2 text-sm">
+              <span>{t('about', { code })}</span>
+              <Button
+                type="button"
+                variant="ghost"
+                size="xs"
+                className="text-muted-foreground"
+                onClick={() => setWithCode(false)}
+              >
+                <X />
+                {t('leaveOut')}
+              </Button>
+            </div>
+          )}
+        </DialogBody>
+
+        <DialogFooter className="items-center sm:justify-between">
+          <p className="text-xs text-muted-foreground">{t('replyHint')}</p>
+          <div className="flex gap-2">
+            <Button
+              type="button"
+              variant="ghost"
+              disabled={sending}
+              onClick={() => onOpenChange(false)}
+            >
+              {t('cancel')}
+            </Button>
+            <Button
+              type="button"
+              disabled={!message.trim() || tooLong || sending}
+              onClick={() => void send()}
+            >
+              {sending ? <Loader2 className="animate-spin" /> : <Send />}
+              {t('send')}
+            </Button>
+          </div>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 567 - 0
src/features/fault-codes/Components/ImportCodesDialog.tsx

@@ -0,0 +1,567 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import { Braces, FileSpreadsheet, Loader2, Send, Table2, Upload, X } from 'lucide-react'
+import { toast } from 'sonner'
+import { useServiceType } from '@/components/service-type-context'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { useFormatDate } from '@/lib/use-format-date'
+import { track } from '@/lib/analytics'
+import { cn } from '@/lib/utils'
+import { type Locale, localeNames, locales } from '@/i18n/config'
+import type {
+  CodeImportChecked,
+  CodeImportCounts,
+  CodeImportStatus,
+} from '@/features/integrations/Lib/types'
+import { getCodeImports } from '../Actions/getCodeImports'
+import { MakeCombobox, type PickedMake } from './MakeCombobox'
+import { MAKES, makeKey, makeName } from '../Lib/makes'
+
+const CHECK_URL = '/api/protected/fault-codes/import/check'
+const SEND_URL = '/api/protected/fault-codes/import'
+const EXAMPLE_URL = '/api/protected/fault-codes/import/example'
+const FILE_PROBLEMS = [
+  'unreadable',
+  'empty',
+  'no_code_column',
+  'too_many',
+  'format',
+  'legacy_xls',
+] as const
+const FAILURES = ['paused', 'nothing', 'demo', 'rights', 'too_large'] as const
+
+type Row = CodeImportChecked & { title: string | null }
+type Checked = { counts: CodeImportCounts; rows: Row[] }
+type Reply<T> = { success: true; data: T } | { success: false; error?: string; problem?: string }
+
+/** A JSON file's own make, language and source, to fill the fields the person left empty. */
+async function jsonMeta(
+  file: File
+): Promise<{ make?: string; language?: string; source?: string }> {
+  if (!file.name.toLowerCase().endsWith('.json')) return {}
+  try {
+    const parsed = JSON.parse(await file.text())
+    if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') return {}
+    const s = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
+    return {
+      make: s(parsed.make),
+      language: s(parsed.language ?? parsed.lang),
+      source: s(parsed.source),
+    }
+  } catch {
+    return {}
+  }
+}
+
+/**
+ * Importing fault codes from a file: a make's own codes Torqvoice does not have yet, or more
+ * text for codes it has. The examples show the shape; the file is read and every code checked
+ * the moment it is dropped, so the person sees what is new, what adds to ours and what to fix
+ * before anything is sent. What is sent waits for a person at Torqvoice to publish it.
+ */
+export function ImportCodesDialog({
+  open,
+  onOpenChange,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}) {
+  const t = useTranslations('faultCodes.import')
+  const locale = useLocale()
+  const isMarine = useServiceType() === 'marine'
+  const { formatDate } = useFormatDate()
+  const [make, setMake] = useState<PickedMake | null>(null)
+  const [lang, setLang] = useState<string>(
+    (locales as readonly string[]).includes(locale) ? locale : 'en'
+  )
+  const [source, setSource] = useState('')
+  const [file, setFile] = useState<File | null>(null)
+  const [checking, setChecking] = useState(false)
+  const [checked, setChecked] = useState<Checked | null>(null)
+  const [fileProblem, setFileProblem] = useState<string | null>(null)
+  const [rights, setRights] = useState(false)
+  const [sending, setSending] = useState(false)
+  const [dragging, setDragging] = useState(false)
+  const [imports, setImports] = useState<CodeImportStatus[]>([])
+  const fileInput = useRef<HTMLInputElement>(null)
+  const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' })
+
+  const makeKeyChosen = make?.key ?? null
+
+  // Earlier imports and how far their review has come, each time the dialog opens.
+  useEffect(() => {
+    if (!open) return
+    let cancelled = false
+    getCodeImports()
+      .then((r) => {
+        if (!cancelled && r.success && r.data) setImports(r.data)
+      })
+      .catch(() => undefined)
+    return () => {
+      cancelled = true
+    }
+  }, [open])
+
+  // The file is checked as soon as there is one and a make to check it for.
+  useEffect(() => {
+    if (!file || !makeKeyChosen) {
+      setChecked(null)
+      return
+    }
+    let cancelled = false
+    setChecking(true)
+    setFileProblem(null)
+    const form = new FormData()
+    form.append('file', file, file.name)
+    form.append('make', makeKeyChosen)
+    form.append('lang', lang)
+    fetch(CHECK_URL, { method: 'POST', body: form })
+      .then((res) => res.json().catch(() => null) as Promise<Reply<Checked> | null>)
+      .then((reply) => {
+        if (cancelled) return
+        if (reply?.success) {
+          setChecked(reply.data)
+          const c = reply.data.counts
+          track('fault_code:import_check', {
+            make: makeKeyChosen,
+            file_type: file.name.split('.').pop()?.toLowerCase() ?? '',
+            rows: reply.data.rows.length,
+            new: c.new,
+            change: c.change,
+            same: c.same,
+            problem: c.problem,
+          })
+          return
+        }
+        setChecked(null)
+        setFileProblem(FILE_PROBLEMS.find((p) => p === reply?.problem) ?? 'failed')
+      })
+      .catch(() => {
+        if (!cancelled) setFileProblem('failed')
+      })
+      .finally(() => {
+        if (!cancelled) setChecking(false)
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [file, makeKeyChosen, lang])
+
+  const pick = async (picked: File | undefined) => {
+    if (!picked) return
+    setFile(picked)
+    setRights(false)
+    if (fileInput.current) fileInput.current.value = ''
+    const meta = await jsonMeta(picked)
+    if (meta.make && !make) {
+      const key = makeKey(meta.make)
+      if (key) setMake({ key, name: MAKES.find((m) => m.key === key)?.name ?? meta.make })
+    }
+    if (meta.language && (locales as readonly string[]).includes(meta.language))
+      setLang(meta.language)
+    if (meta.source && !source) setSource(meta.source)
+  }
+
+  const clearFile = () => {
+    setFile(null)
+    setChecked(null)
+    setFileProblem(null)
+    setRights(false)
+  }
+
+  const toSend = checked ? checked.counts.new + checked.counts.change : 0
+
+  const send = async () => {
+    if (!file || !makeKeyChosen || !rights || toSend === 0 || sending) return
+    setSending(true)
+    const form = new FormData()
+    form.append('file', file, file.name)
+    form.append('make', makeKeyChosen)
+    form.append('lang', lang)
+    if (source.trim()) form.append('source', source.trim())
+    form.append('rights', 'yes')
+    let reply: Reply<{ id: string; counts: CodeImportCounts; imports: CodeImportStatus[] }> | null =
+      null
+    try {
+      const res = await fetch(SEND_URL, { method: 'POST', body: form })
+      reply = await res.json().catch(() => null)
+    } catch {
+      reply = null
+    }
+    setSending(false)
+    if (reply?.success) {
+      track('fault_code:import_send', {
+        make: makeKeyChosen,
+        codes: reply.data.counts.new + reply.data.counts.change,
+        new: reply.data.counts.new,
+        change: reply.data.counts.change,
+        source: Boolean(source.trim()),
+      })
+      setImports(reply.data.imports)
+      clearFile()
+      onOpenChange(false)
+      toast.success(t('sent', { count: reply.data.counts.new + reply.data.counts.change }))
+      return
+    }
+    const failure = FAILURES.find((f) => f === reply?.error)
+    toast.error(failure ? t(`errors.${failure}`) : t('errors.failed'))
+  }
+
+  const rowState = (r: Row) => {
+    if (r.state === 'problem')
+      return t(`problems.${r.problem ?? 'code'}`, { make: make?.name ?? '' })
+    if (r.state === 'new') return t('state.new')
+    const what = r.fields.filter((f) => f !== 'causes').map((f) => t(`fields.${f}`))
+    if (r.addedCauses > 0) what.push(t('addsCauses', { count: r.addedCauses }))
+    else if (r.fields.includes('causes')) what.push(t('fields.causes'))
+    return t('state.change', { what: list.format(what) })
+  }
+  // Problems first, so they are fixed before anything else is read.
+  const shown = checked
+    ? [
+        ...checked.rows.filter((r) => r.state === 'problem'),
+        ...checked.rows.filter((r) => r.state === 'new' || r.state === 'change'),
+      ]
+    : []
+
+  const missing = !makeKeyChosen
+    ? t('missing.make')
+    : !file
+      ? t('missing.file')
+      : checking || !checked
+        ? null
+        : toSend === 0
+          ? t('missing.nothing')
+          : !rights
+            ? t('missing.rights')
+            : null
+
+  return (
+    <Dialog open={open} onOpenChange={(next) => !sending && onOpenChange(next)}>
+      <DialogContent className="sm:max-w-3xl">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+
+        <DialogBody className="space-y-6">
+          {imports.length > 0 && (
+            <section className="space-y-1.5" aria-label={t('yours')}>
+              <h3 className="text-sm font-medium">{t('yours')}</h3>
+              <ul className="divide-y divide-card-edge/60 rounded-lg border border-card-edge text-sm">
+                {imports.slice(0, 3).map((i) => (
+                  <li
+                    key={i.id}
+                    className="flex flex-wrap items-center justify-between gap-x-3 gap-y-0.5 px-3 py-2"
+                  >
+                    <span className="font-medium">
+                      {makeName(i.make)}
+                      <span className="ml-2 font-normal text-muted-foreground">
+                        {t('codes', { count: i.counts.new + i.counts.change })}
+                      </span>
+                    </span>
+                    <span className="text-xs text-muted-foreground">
+                      {[
+                        i.counts.published > 0
+                          ? t('status.published', { count: i.counts.published })
+                          : null,
+                        i.counts.pending > 0
+                          ? t('status.pending', { count: i.counts.pending })
+                          : null,
+                        i.counts.rejected > 0
+                          ? t('status.rejected', { count: i.counts.rejected })
+                          : null,
+                        formatDate(i.createdAt),
+                      ]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </span>
+                  </li>
+                ))}
+              </ul>
+            </section>
+          )}
+
+          <section className="space-y-2">
+            <h3 className="text-sm font-medium">{t('examples.title')}</h3>
+            <div className="grid gap-3 sm:grid-cols-2">
+              <a
+                href={`${EXAMPLE_URL}?format=xlsx`}
+                download
+                onClick={() => track('fault_code:import_example_download', { format: 'xlsx' })}
+                className="flex items-center gap-3 rounded-lg border border-card-edge px-3.5 py-3 transition-colors hover:border-primary/40 hover:bg-primary/5"
+              >
+                <Table2 className="size-5 shrink-0 text-primary" aria-hidden />
+                <span className="min-w-0">
+                  <span className="block text-sm font-medium">{t('examples.sheet')}</span>
+                  <span className="block text-xs text-muted-foreground">
+                    {t('examples.sheetHint')}
+                  </span>
+                </span>
+              </a>
+              <a
+                href={`${EXAMPLE_URL}?format=json`}
+                download
+                onClick={() => track('fault_code:import_example_download', { format: 'json' })}
+                className="flex items-center gap-3 rounded-lg border border-card-edge px-3.5 py-3 transition-colors hover:border-primary/40 hover:bg-primary/5"
+              >
+                <Braces className="size-5 shrink-0 text-primary" aria-hidden />
+                <span className="min-w-0">
+                  <span className="block text-sm font-medium">{t('examples.json')}</span>
+                  <span className="block text-xs text-muted-foreground">
+                    {t('examples.jsonHint')}
+                  </span>
+                </span>
+              </a>
+            </div>
+            <p className="text-xs text-muted-foreground">
+              {t('examples.columns')}{' '}
+              <a
+                href={`${EXAMPLE_URL}?format=csv`}
+                download
+                onClick={() => track('fault_code:import_example_download', { format: 'csv' })}
+                className="underline underline-offset-2 hover:text-foreground"
+              >
+                {t('examples.csv')}
+              </a>
+            </p>
+          </section>
+
+          <section className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.3fr)]">
+            <div className="space-y-1.5">
+              <Label htmlFor="import-make" className="text-xs text-muted-foreground">
+                {t('make')}
+              </Label>
+              <MakeCombobox id="import-make" value={make} onChange={setMake} marine={isMarine} />
+            </div>
+            <div className="space-y-1.5">
+              <Label htmlFor="import-lang" className="text-xs text-muted-foreground">
+                {t('language')}
+              </Label>
+              <Select value={lang} onValueChange={setLang}>
+                <SelectTrigger id="import-lang" className="w-full">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {locales.map((l) => (
+                    <SelectItem key={l} value={l}>
+                      {localeNames[l as Locale]}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+            <div className="space-y-1.5">
+              <Label htmlFor="import-source" className="text-xs text-muted-foreground">
+                {t('source')}
+              </Label>
+              <Input
+                id="import-source"
+                value={source}
+                maxLength={500}
+                placeholder={t('sourcePlaceholder')}
+                onChange={(e) => setSource(e.target.value)}
+              />
+            </div>
+          </section>
+
+          <section className="space-y-3">
+            {file ? (
+              <div className="flex items-center gap-3 rounded-lg border border-card-edge px-3 py-2.5">
+                <FileSpreadsheet className="size-5 shrink-0 text-primary" aria-hidden />
+                <span className="min-w-0 flex-1">
+                  <span className="block truncate text-sm font-medium">{file.name}</span>
+                  <span className="block text-xs text-muted-foreground">
+                    {checking
+                      ? t('checking')
+                      : checked
+                        ? t('codes', { count: checked.rows.length })
+                        : !makeKeyChosen
+                          ? t('missing.make')
+                          : ''}
+                  </span>
+                </span>
+                {checking && (
+                  <Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
+                )}
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="icon-sm"
+                  className="text-muted-foreground"
+                  aria-label={t('removeFile')}
+                  onClick={clearFile}
+                >
+                  <X />
+                </Button>
+              </div>
+            ) : (
+              <button
+                type="button"
+                onClick={() => fileInput.current?.click()}
+                onDragOver={(e) => {
+                  if (!e.dataTransfer.types.includes('Files')) return
+                  e.preventDefault()
+                  setDragging(true)
+                }}
+                onDragLeave={() => setDragging(false)}
+                onDrop={(e) => {
+                  e.preventDefault()
+                  setDragging(false)
+                  void pick(e.dataTransfer.files[0])
+                }}
+                className={cn(
+                  'flex w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed border-border bg-muted/20 px-4 py-8 text-center text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground',
+                  dragging && 'border-primary/60 bg-primary/5'
+                )}
+              >
+                <Upload className="size-5" aria-hidden />
+                <span className="text-sm font-medium text-foreground">{t('drop')}</span>
+                <span>{t('dropTypes')}</span>
+              </button>
+            )}
+            <input
+              ref={fileInput}
+              type="file"
+              accept=".xlsx,.xlsm,.csv,.txt,.json"
+              hidden
+              data-testid="import-file-input"
+              onChange={(e) => void pick(e.target.files?.[0])}
+            />
+            {fileProblem && (
+              <p className="text-sm font-medium text-destructive">
+                {fileProblem === 'failed'
+                  ? t('errors.failed')
+                  : t(`fileProblems.${fileProblem as (typeof FILE_PROBLEMS)[number]}`)}
+              </p>
+            )}
+
+            {checked && (
+              <>
+                <div className="flex flex-wrap gap-2">
+                  {checked.counts.new > 0 && (
+                    <span className="rounded-md bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
+                      {t('counts.new', { count: checked.counts.new })}
+                    </span>
+                  )}
+                  {checked.counts.change > 0 && (
+                    <span className="rounded-md bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
+                      {t('counts.change', { count: checked.counts.change })}
+                    </span>
+                  )}
+                  {checked.counts.problem > 0 && (
+                    <span className="rounded-md bg-destructive/10 px-2.5 py-1 text-xs font-medium text-destructive">
+                      {t('counts.problem', { count: checked.counts.problem })}
+                    </span>
+                  )}
+                  {checked.counts.same > 0 && (
+                    <span className="rounded-md bg-muted px-2.5 py-1 text-xs text-muted-foreground">
+                      {t('counts.same', { count: checked.counts.same })}
+                    </span>
+                  )}
+                </div>
+                {shown.length > 0 && (
+                  <ul
+                    className="divide-y divide-card-edge/60 rounded-lg border border-card-edge text-sm"
+                    aria-label={t('rows')}
+                  >
+                    {shown.slice(0, 200).map((r) => (
+                      <li
+                        key={r.n}
+                        className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 px-3 py-2 sm:grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,14rem)]"
+                      >
+                        <span className="font-mono text-xs leading-5">{r.code}</span>
+                        <span className="truncate">
+                          {r.title ?? (
+                            <span className="text-muted-foreground">{t('row', { n: r.n })}</span>
+                          )}
+                        </span>
+                        <span
+                          className={cn(
+                            'col-start-2 text-xs sm:col-start-auto sm:text-right',
+                            r.state === 'problem'
+                              ? 'text-destructive'
+                              : r.state === 'new'
+                                ? 'text-emerald-700 dark:text-emerald-300'
+                                : 'text-primary'
+                          )}
+                        >
+                          {r.state === 'problem'
+                            ? `${t('row', { n: r.n })}: ${rowState(r)}`
+                            : rowState(r)}
+                        </span>
+                      </li>
+                    ))}
+                  </ul>
+                )}
+                {shown.length > 200 && (
+                  <p className="text-xs text-muted-foreground">
+                    {t('more', { count: shown.length - 200 })}
+                  </p>
+                )}
+                {checked.counts.problem > 0 && toSend > 0 && (
+                  <p className="text-xs text-muted-foreground">{t('problemsLeftOut')}</p>
+                )}
+                {toSend > 0 && (
+                  <label className="flex items-start gap-2.5 text-sm">
+                    <Checkbox
+                      checked={rights}
+                      onCheckedChange={(v) => setRights(v === true)}
+                      className="mt-0.5"
+                    />
+                    <span>{t('rights')}</span>
+                  </label>
+                )}
+              </>
+            )}
+          </section>
+        </DialogBody>
+
+        <DialogFooter className="items-center sm:justify-between">
+          <p className="text-xs text-muted-foreground" aria-live="polite">
+            {missing ?? t('reviewHint')}
+          </p>
+          <div className="flex gap-2">
+            <Button
+              type="button"
+              variant="ghost"
+              disabled={sending}
+              onClick={() => onOpenChange(false)}
+            >
+              {t('cancel')}
+            </Button>
+            <Button
+              type="button"
+              disabled={Boolean(missing) || checking || sending || toSend === 0}
+              onClick={() => void send()}
+            >
+              {sending ? <Loader2 className="animate-spin" /> : <Send />}
+              {toSend > 0 ? t('send', { count: toSend }) : t('sendNone')}
+            </Button>
+          </div>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 21 - 0
src/features/fault-codes/Components/Likelihood.tsx

@@ -0,0 +1,21 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+
+/** High, medium or low as a small bar of three, with the word for screen readers. */
+export function Likelihood({ level }: { level: 'low' | 'medium' | 'high' }) {
+  const t = useTranslations('faultCodes.detail.likelihood')
+  const filled = level === 'high' ? 3 : level === 'medium' ? 2 : 1
+  return (
+    <span className="mt-1.5 flex shrink-0 items-center gap-0.5" title={t(level)}>
+      {[1, 2, 3].map((i) => (
+        <span
+          key={i}
+          className={cn('h-1.5 w-3 rounded-full', i <= filled ? 'bg-primary' : 'bg-muted')}
+        />
+      ))}
+      <span className="sr-only">{t(level)}</span>
+    </span>
+  )
+}

+ 130 - 0
src/features/fault-codes/Components/MakeCombobox.tsx

@@ -0,0 +1,130 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Check, ChevronsUpDown, Plus } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+import { MAKES, type Make, makeKey } from '../Lib/makes'
+
+export type PickedMake = { key: string; name: string }
+
+/**
+ * The make an import is for: type a few letters and pick it from the list, cars first for a
+ * workshop that fixes cars and outboards first for a marine one. A make that is not listed is
+ * used as typed. The same Popover and Command picker as the app's other searchable fields.
+ */
+export function MakeCombobox({
+  value,
+  onChange,
+  marine,
+  id,
+}: {
+  value: PickedMake | null
+  onChange: (make: PickedMake) => void
+  marine: boolean
+  id?: string
+}) {
+  const t = useTranslations('faultCodes.import')
+  const [open, setOpen] = useState(false)
+  const [search, setSearch] = useState('')
+  const typed = search.trim()
+  const typedKey = typed ? makeKey(typed) : null
+  // A listed make that has the typed letters in its name; only without one is the typed
+  // name offered as a make of its own, so Enter on a half-typed name never makes one up.
+  const lower = typed.toLowerCase()
+  const listed = MAKES.some(
+    (m) => m.name.toLowerCase().includes(lower) || (typedKey !== null && m.key.includes(typedKey))
+  )
+  const groups: { label: string; makes: Make[] }[] = [
+    { label: t('groups.cars'), makes: MAKES.filter((m) => !m.marine) },
+    { label: t('groups.marine'), makes: MAKES.filter((m) => m.marine) },
+  ]
+  if (marine) groups.reverse()
+
+  const pick = (make: PickedMake) => {
+    onChange(make)
+    setSearch('')
+    setOpen(false)
+  }
+
+  return (
+    // Modal: inside the import dialog a non-modal popover's wheel events are swallowed by the
+    // dialog's scroll lock, and the list cannot be scrolled.
+    <Popover open={open} onOpenChange={setOpen} modal>
+      <PopoverTrigger asChild>
+        <Button
+          id={id}
+          type="button"
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          className="h-9 w-full justify-between font-normal"
+        >
+          <span className={cn('truncate', !value && 'text-muted-foreground')}>
+            {value?.name ?? t('pickMake')}
+          </span>
+          <ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" aria-hidden />
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-(--radix-popover-trigger-width) min-w-64 p-0" align="start">
+        <Command
+          filter={(value, query) => {
+            const v = value.toLowerCase()
+            const key = makeKey(query)
+            return v.includes(query.trim().toLowerCase()) || (key !== null && v.includes(key))
+              ? 1
+              : 0
+          }}
+        >
+          <CommandInput placeholder={t('searchMake')} value={search} onValueChange={setSearch} />
+          <CommandList>
+            {!typedKey && (
+              <CommandEmpty>
+                <span className="text-xs text-muted-foreground">{t('noMake')}</span>
+              </CommandEmpty>
+            )}
+            {groups.map((group) => (
+              <CommandGroup key={group.label} heading={group.label}>
+                {group.makes.map((m) => (
+                  <CommandItem
+                    key={m.key}
+                    value={`${m.name} ${m.key}`}
+                    onSelect={() => pick({ key: m.key, name: m.name })}
+                  >
+                    <Check
+                      className={cn('size-3.5', value?.key === m.key ? 'opacity-100' : 'opacity-0')}
+                      aria-hidden
+                    />
+                    {m.name}
+                  </CommandItem>
+                ))}
+              </CommandGroup>
+            ))}
+            {typedKey && !listed && (
+              <CommandGroup forceMount>
+                <CommandItem
+                  forceMount
+                  value={`use:${typed}`}
+                  onSelect={() => pick({ key: typedKey, name: typed })}
+                >
+                  <Plus className="size-3.5" aria-hidden />
+                  {t('useMake', { name: typed })}
+                </CommandItem>
+              </CommandGroup>
+            )}
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 104 - 0
src/features/fault-codes/Components/OemWhereUsed.tsx

@@ -0,0 +1,104 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { modelLines } from '../Lib/oem'
+import type { OemMeaningView } from '../Lib/types'
+
+const UNITS_SHOWN = 6
+
+/**
+ * Where a make uses one meaning of its code: the models and years, the
+ * engines and the control units that give it. On the code's page it fills a
+ * card; beside a job it reads as one more part.
+ */
+export function OemWhereUsed({
+  meaning,
+  layout,
+}: {
+  meaning: OemMeaningView
+  layout: 'page' | 'sheet'
+}) {
+  const t = useTranslations('faultCodes.oem')
+  const [allUnits, setAllUnits] = useState(false)
+  const { lines, other } = modelLines(meaning.vehicles)
+  const units = allUnits ? meaning.modules : meaning.modules.slice(0, UNITS_SHOWN)
+  const page = layout === 'page'
+  const heading = page
+    ? 'text-xs font-semibold uppercase tracking-wide text-muted-foreground'
+    : 'text-[11px] font-medium uppercase tracking-wide text-muted-foreground'
+
+  return (
+    <div className={page ? 'space-y-4' : 'space-y-3'}>
+      {(lines.length > 0 || other.length > 0) && (
+        <section className="space-y-1.5">
+          <h4 className={heading}>{t('models')}</h4>
+          <ul className="space-y-1 text-sm">
+            {lines.map((line) => (
+              <li key={line.model} className="flex flex-wrap items-baseline gap-x-2">
+                <span className="font-medium">{line.model}</span>
+                <span className="font-mono text-xs text-muted-foreground">
+                  {line.chassis.join(', ')}
+                </span>
+                {line.from !== null && (
+                  <span className="ml-auto text-xs tabular-nums text-muted-foreground">
+                    {line.to === null
+                      ? t('yearsFrom', { from: line.from })
+                      : t('years', { from: line.from, to: line.to })}
+                  </span>
+                )}
+              </li>
+            ))}
+            {other.length > 0 && (
+              <li className="flex flex-wrap items-baseline gap-x-2">
+                <span className="font-medium">{t('otherChassis')}</span>
+                <span className="font-mono text-xs text-muted-foreground">{other.join(', ')}</span>
+              </li>
+            )}
+          </ul>
+        </section>
+      )}
+      {meaning.engines.length > 0 && (
+        <section className="space-y-1.5">
+          <h4 className={heading}>{t('engineList')}</h4>
+          <div className="flex flex-wrap gap-1.5">
+            {meaning.engines.map((engine) => (
+              <span
+                key={engine}
+                className="rounded-md border border-card-edge bg-muted/40 px-1.5 py-0.5 font-mono text-xs"
+              >
+                {engine}
+              </span>
+            ))}
+          </div>
+        </section>
+      )}
+      <section className="space-y-1.5">
+        <h4 className={heading}>{t('units')}</h4>
+        <div className="flex flex-wrap items-center gap-1.5">
+          {units.map((u) => (
+            <span
+              key={u.ref}
+              title={u.name ?? undefined}
+              className="rounded-md border border-card-edge bg-muted/40 px-1.5 py-0.5 font-mono text-[11px]"
+            >
+              {u.family ? `${u.family} · ${u.ref}` : u.ref}
+            </span>
+          ))}
+          {meaning.modules.length > UNITS_SHOWN && (
+            <button
+              type="button"
+              onClick={() => setAllUnits((v) => !v)}
+              className="cursor-pointer text-xs font-medium text-primary underline-offset-2 hover:underline"
+            >
+              {allUnits
+                ? t('fewerUnits')
+                : t('moreUnits', { count: meaning.modules.length - UNITS_SHOWN })}
+            </button>
+          )}
+        </div>
+      </section>
+      <p className="text-xs text-muted-foreground">{t('source')}</p>
+    </div>
+  )
+}

+ 159 - 0
src/features/fault-codes/Components/PhoneCode.tsx

@@ -0,0 +1,159 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { QRCodeSVG } from 'qrcode.react'
+import { Check, Copy, Loader2, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { useFormatDate } from '@/lib/use-format-date'
+import { createFixPhoneLink } from '../Actions/createFixPhoneLink'
+import { listFixDraftPhotos } from '../Actions/listFixDraftPhotos'
+import { removeFixDraftPhoto } from '../Actions/removeFixDraftPhoto'
+
+const POLL_MS = 2500
+
+/** A v4 UUID, also where `crypto.randomUUID` is missing (plain http on a LAN). */
+export function uuid(): string {
+  if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
+  const b = crypto.getRandomValues(new Uint8Array(16))
+  b[6] = (b[6] & 0x0f) | 0x40
+  b[8] = (b[8] & 0x3f) | 0x80
+  const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
+  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
+}
+
+export type PhoneLink = { url: string; expiresAt: Date }
+export type PhonePhoto = { id: string; url: string }
+
+/**
+ * Photos taken with a phone for a draft on the computer: while `active`, a fresh code for the
+ * phone (the last may have lapsed), and the photos as they land. A waveform's code asks the
+ * phone for one picture of the scope screen; a fix's for up to four photos.
+ */
+export function usePhoneDraft({
+  active,
+  code,
+  draftId,
+  purpose,
+}: {
+  active: boolean
+  code: string
+  draftId: string
+  purpose: 'fix' | 'waveform'
+}) {
+  const [link, setLink] = useState<PhoneLink | null>(null)
+  const [failed, setFailed] = useState(false)
+  const [photos, setPhotos] = useState<PhonePhoto[]>([])
+
+  useEffect(() => {
+    if (!active) return
+    let cancelled = false
+    setLink(null)
+    setFailed(false)
+    createFixPhoneLink({ code, draftId, purpose })
+      .then((result) => {
+        if (cancelled) return
+        if (!result.success || !result.data) {
+          setFailed(true)
+          return
+        }
+        setLink({
+          url: `${window.location.origin}/p/${result.data.token}`,
+          expiresAt: new Date(result.data.expiresAt),
+        })
+      })
+      .catch(() => {
+        if (!cancelled) setFailed(true)
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [active, code, draftId, purpose])
+
+  useEffect(() => {
+    if (!active) return
+    const read = async () => {
+      const result = await listFixDraftPhotos({ draftId }).catch(() => null)
+      if (result?.success && result.data) setPhotos(result.data)
+    }
+    void read()
+    const timer = setInterval(read, POLL_MS)
+    return () => clearInterval(timer)
+  }, [active, draftId])
+
+  const remove = async (photoId: string) => {
+    setPhotos((current) => current.filter((p) => p.id !== photoId))
+    await removeFixDraftPhoto({ draftId, photoId }).catch(() => null)
+  }
+
+  return { link, failed, photos, setPhotos, remove }
+}
+
+/** The code to scan, how long it works, a link to copy, and what has arrived so far. */
+export function PhoneCodePanel({
+  link,
+  failed,
+  title,
+  body,
+  received,
+}: {
+  link: PhoneLink | null
+  failed: boolean
+  title: string
+  body: string
+  /** "2 photos from your phone", once there are some. */
+  received?: string | null
+}) {
+  const t = useTranslations('faultCodes.share.phone')
+  const { formatTime } = useFormatDate()
+  const [copied, setCopied] = useState(false)
+
+  useEffect(() => {
+    if (!copied) return
+    const timer = setTimeout(() => setCopied(false), 1500)
+    return () => clearTimeout(timer)
+  }, [copied])
+
+  return (
+    <div className="flex flex-col items-center gap-4 rounded-lg border border-card-edge bg-muted/30 p-4 sm:flex-row sm:items-start">
+      <div className="flex size-[164px] shrink-0 items-center justify-center rounded-lg bg-white p-2 shadow-sm">
+        {link ? (
+          <QRCodeSVG value={link.url} size={148} marginSize={0} />
+        ) : failed ? (
+          <X className="size-6 text-muted-foreground" aria-hidden />
+        ) : (
+          <Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
+        )}
+      </div>
+      <div className="min-w-0 flex-1 space-y-2 text-center sm:text-left">
+        <p className="text-sm font-medium">{title}</p>
+        <p className="text-sm text-muted-foreground">{failed ? t('failed') : body}</p>
+        {link && (
+          <>
+            <p className="text-xs text-muted-foreground">
+              {t('expires', { time: formatTime(link.expiresAt) })}
+            </p>
+            <Button
+              type="button"
+              variant="ghost"
+              size="xs"
+              className="text-muted-foreground"
+              onClick={() => {
+                void navigator.clipboard?.writeText(link.url)
+                setCopied(true)
+              }}
+            >
+              {copied ? <Check /> : <Copy />}
+              {copied ? t('copied') : t('copy')}
+            </Button>
+          </>
+        )}
+        {received && (
+          <p className="text-xs font-medium text-primary" aria-live="polite">
+            {received}
+          </p>
+        )}
+      </div>
+    </div>
+  )
+}

+ 2 - 0
src/features/fault-codes/Components/ReportMeaningPopover.tsx

@@ -4,6 +4,7 @@ import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { Flag, Loader2 } from 'lucide-react'
 import { Flag, Loader2 } from 'lucide-react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
+import { track } from '@/lib/analytics'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
@@ -57,6 +58,7 @@ export function ReportMeaningPopover({
     })
     })
     setSending(false)
     setSending(false)
     if (result.success && result.data?.sent && result.data.report) {
     if (result.success && result.data?.sent && result.data.report) {
+      track('fault_code:report_send', { code, reason })
       toast.success(t('thanks'))
       toast.success(t('thanks'))
       onReported(result.data.report)
       onReported(result.data.report)
       setOpen(false)
       setOpen(false)

+ 338 - 0
src/features/fault-codes/Components/ResearchCard.tsx

@@ -0,0 +1,338 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import {
+  CheckCircle2,
+  ExternalLink,
+  Loader2,
+  SearchX,
+  ShieldCheck,
+  Sparkles,
+  ThumbsDown,
+  ThumbsUp,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { track } from '@/lib/analytics'
+import { AppCard } from '@/components/app-card'
+import { Button } from '@/components/ui/button'
+import type { FaultCodeResearch } from '@/features/integrations/Lib/types'
+import { useFormatDate } from '@/lib/use-format-date'
+import { cn } from '@/lib/utils'
+import { rateFaultCodeResearch } from '../Actions/rateFaultCodeResearch'
+import { researchFaultCode } from '../Actions/researchFaultCode'
+import type { ResearchState } from '../Lib/types'
+import { Likelihood } from './Likelihood'
+
+const LIKELIHOOD_RANK = { high: 0, medium: 1, low: 2 } as const
+/** How often the working card names its next step. */
+const STEP_MS = 7000
+
+/**
+ * "Research a fix" on a code's page: AI searches manufacturer documents, bulletins and
+ * workshop forums, and only a specific answer with sources is kept, for a person at Torqvoice
+ * to check. What one workshop researched every workshop sees, so a code is researched once.
+ * A search that found nothing says so plainly, and nothing is saved.
+ */
+export function ResearchCard({
+  code,
+  label,
+  meaning = null,
+  vehicleId = null,
+  state,
+}: {
+  /** The code as the service keys it: P0420, or BMW-2A82 for a make's own. */
+  code: string
+  /** The code as people read it: P0420, BMW 2A82. */
+  label: string
+  /** For a make's code, the meaning on screen; research is per meaning. */
+  meaning?: { id: string; title: string } | null
+  vehicleId?: string | null
+  state: ResearchState
+}) {
+  const t = useTranslations('faultCodes.research')
+  const router = useRouter()
+  const [items, setItems] = useState(state.items)
+  // "Check again" reads the page again: take what it brings, a finished research or a review.
+  useEffect(() => {
+    setItems(state.items)
+  }, [state.items])
+  const [working, setWorking] = useState(false)
+  const item = items.find((i) => (i.meaning ?? null) === (meaning?.id ?? null)) ?? null
+
+  const start = async () => {
+    setWorking(true)
+    track('fault_code:research_start', { code })
+    const result = await researchFaultCode({ code, meaning: meaning?.id, vehicleId })
+    setWorking(false)
+    if (!result.success || !result.data) {
+      track('fault_code:research_result', { code, outcome: 'failed' })
+      toast.error(t('errors.failed'))
+      return
+    }
+    const outcome = result.data
+    track('fault_code:research_result', {
+      code,
+      outcome: outcome.outcome === 'done' ? outcome.research.status : outcome.outcome,
+    })
+    if (outcome.outcome === 'done') {
+      const found = outcome.research
+      setItems((current) => [
+        ...current.filter(
+          (i) => i.id !== found.id && (i.meaning ?? null) !== (found.meaning ?? null)
+        ),
+        found,
+      ])
+      return
+    }
+    toast.error(
+      t(
+        `errors.${outcome.outcome === 'busy' || outcome.outcome === 'limited' ? outcome.outcome : 'failed'}`
+      )
+    )
+  }
+
+  const subject = meaning ? `${label} · ${meaning.title}` : label
+
+  if (working) return <Working subject={subject} />
+  if (!item) {
+    if (!state.available) return null
+    return (
+      <AppCard icon={Sparkles} title={t('inviteTitle')}>
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+          <p className="text-sm text-muted-foreground">{t('invite', { code: subject })}</p>
+          <Button type="button" onClick={start} className="shrink-0 gap-1.5">
+            <Sparkles className="size-4" />
+            {t('button')}
+          </Button>
+        </div>
+      </AppCard>
+    )
+  }
+  if (item.status === 'running') {
+    return (
+      <AppCard icon={Sparkles} title={t('runningTitle')}>
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+          <p className="text-sm text-muted-foreground">{t('runningHint')}</p>
+          <Button type="button" variant="outline" onClick={() => router.refresh()}>
+            {t('checkAgain')}
+          </Button>
+        </div>
+      </AppCard>
+    )
+  }
+  if (item.status === 'missed' || !item.findings) return <Missed item={item} subject={subject} />
+  return <Findings item={item} />
+}
+
+/** While the research runs: what it is doing, so a minute of waiting reads as work. */
+function Working({ subject }: { subject: string }) {
+  const t = useTranslations('faultCodes.research')
+  const steps = [t('steps.bulletins'), t('steps.forums'), t('steps.agree'), t('steps.writing')]
+  const [step, setStep] = useState(0)
+  useEffect(() => {
+    const timer = setInterval(() => setStep((s) => Math.min(s + 1, 3)), STEP_MS)
+    return () => clearInterval(timer)
+  }, [])
+  return (
+    <AppCard icon={Sparkles} title={t('working', { code: subject })}>
+      <div className="space-y-3" aria-live="polite">
+        <ol className="space-y-1.5 text-sm">
+          {steps.map((s, i) => (
+            <li
+              key={s}
+              className={cn(
+                'flex items-center gap-2',
+                i < step && 'text-muted-foreground',
+                i > step && 'text-muted-foreground/50'
+              )}
+            >
+              {i < step ? (
+                <CheckCircle2 className="size-4 text-primary" aria-hidden />
+              ) : i === step ? (
+                <Loader2 className="size-4 animate-spin text-primary" aria-hidden />
+              ) : (
+                <span className="size-4 rounded-full border" aria-hidden />
+              )}
+              {s}
+            </li>
+          ))}
+        </ol>
+        <p className="text-xs text-muted-foreground">{t('workingHint')}</p>
+      </div>
+    </AppCard>
+  )
+}
+
+/** Nothing specific found: said plainly, nothing saved, and the way to help instead. */
+function Missed({ item, subject }: { item: FaultCodeResearch; subject: string }) {
+  const t = useTranslations('faultCodes.research')
+  const { formatDate } = useFormatDate()
+  return (
+    <AppCard icon={SearchX} title={t('missedTitle')}>
+      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+        <div className="space-y-1 text-sm">
+          <p className="text-muted-foreground">{t('missed', { code: subject })}</p>
+          <p className="text-xs text-muted-foreground">
+            {t('searchedOn', { date: formatDate(item.createdAt) })}
+          </p>
+        </div>
+        <Button
+          type="button"
+          variant="outline"
+          className="shrink-0"
+          onClick={() =>
+            document.getElementById('share-fix')?.scrollIntoView({ behavior: 'smooth' })
+          }
+        >
+          {t('shareInstead')}
+        </Button>
+      </div>
+    </AppCard>
+  )
+}
+
+/** What was found, laid out like the page: meaning, issues, steps, causes, advice, sources. */
+function Findings({ item }: { item: FaultCodeResearch }) {
+  const t = useTranslations('faultCodes.research')
+  const tc = useTranslations('faultCodes')
+  const f = item.findings
+  const [vote, setVote] = useState<boolean | null>(null)
+  if (!f) return null
+  const checked = item.status === 'published'
+  const causes = [...f.causes].sort(
+    (a, b) => LIKELIHOOD_RANK[a.likelihood] - LIKELIHOOD_RANK[b.likelihood]
+  )
+  const rate = async (helpful: boolean) => {
+    setVote(helpful)
+    track('fault_code:research_rate', { code: item.code, helpful })
+    const result = await rateFaultCodeResearch({ id: item.id, helpful })
+    if (!result.success || !result.data?.rated) {
+      setVote(null)
+      toast.error(t('errors.failed'))
+    }
+  }
+  const heading = 'text-xs font-semibold uppercase tracking-wide text-muted-foreground'
+
+  return (
+    <AppCard
+      icon={Sparkles}
+      title={t('title')}
+      action={
+        <span
+          className={cn(
+            'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium',
+            checked
+              ? 'border-primary/30 bg-primary/10 text-primary'
+              : 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400'
+          )}
+        >
+          {checked && <ShieldCheck className="size-3" aria-hidden />}
+          {checked ? t('published') : t('pending')}
+        </span>
+      }
+      contentClassName="space-y-5"
+    >
+      <div className="space-y-1.5">
+        <p className="font-medium">{f.title}</p>
+        <p className="max-w-[88ch] text-sm leading-7 text-foreground/90">{f.meaning}</p>
+        {!checked && <p className="text-xs text-muted-foreground">{t('pendingHint')}</p>}
+      </div>
+
+      {f.commonIssues.length > 0 && (
+        <section className="space-y-1.5 border-l-2 border-primary/30 pl-4">
+          <h4 className={heading}>{tc('sections.common_issues')}</h4>
+          <ul className="list-disc space-y-1 pl-4 text-sm">
+            {f.commonIssues.map((issue) => (
+              <li key={issue}>{issue}</li>
+            ))}
+          </ul>
+        </section>
+      )}
+
+      <section className="space-y-1.5 border-l-2 border-primary/30 pl-4">
+        <h4 className={heading}>{tc('sections.resolution')}</h4>
+        <ol className="space-y-2 text-sm">
+          {f.steps.map((step, i) => (
+            <li key={step} className="flex gap-3">
+              <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
+                {i + 1}
+              </span>
+              <span className="leading-6">{step}</span>
+            </li>
+          ))}
+        </ol>
+      </section>
+
+      {causes.length > 0 && (
+        <section className="space-y-2 border-l-2 border-primary/30 pl-4">
+          <h4 className={heading}>{tc('detail.likelyCauses')}</h4>
+          <ul className="space-y-1.5">
+            {causes.map((cause) => (
+              <li key={cause.label} className="flex items-start gap-3">
+                <Likelihood level={cause.likelihood} />
+                <span className="min-w-0 flex-1 text-sm">{cause.label}</span>
+              </li>
+            ))}
+          </ul>
+        </section>
+      )}
+
+      {f.advice && (
+        <section className="space-y-1.5 border-l-2 border-primary/30 pl-4">
+          <h4 className={heading}>{tc('sections.advisory')}</h4>
+          <p className="text-sm">{f.advice}</p>
+        </section>
+      )}
+
+      <div className="flex flex-wrap items-center gap-2 border-t border-card-edge pt-4">
+        <span className="text-xs text-muted-foreground">{t('sources')}</span>
+        {f.sources.map((src) => (
+          <a
+            key={src.url}
+            href={src.url}
+            target="_blank"
+            rel="noopener noreferrer nofollow"
+            title={src.title ?? src.url}
+            className="inline-flex items-center gap-1 rounded-md border border-card-edge bg-muted/40 px-1.5 py-0.5 text-xs hover:border-primary/40 hover:text-primary"
+          >
+            {hostOf(src.url)}
+            <ExternalLink className="size-3" aria-hidden />
+          </a>
+        ))}
+        <span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
+          {vote === null ? t('helpfulQuestion') : t('thanks')}
+          <Button
+            type="button"
+            variant={vote === true ? 'default' : 'outline'}
+            size="icon-sm"
+            aria-label={t('helpfulYes')}
+            aria-pressed={vote === true}
+            onClick={() => rate(true)}
+          >
+            <ThumbsUp className="size-3.5" />
+          </Button>
+          <Button
+            type="button"
+            variant={vote === false ? 'default' : 'outline'}
+            size="icon-sm"
+            aria-label={t('helpfulNo')}
+            aria-pressed={vote === false}
+            onClick={() => rate(false)}
+          >
+            <ThumbsDown className="size-3.5" />
+          </Button>
+        </span>
+      </div>
+    </AppCard>
+  )
+}
+
+function hostOf(url: string): string {
+  try {
+    return new URL(url).hostname.replace(/^www\./, '')
+  } catch {
+    return url
+  }
+}

+ 20 - 4
src/features/fault-codes/Components/ShareFixCard.tsx

@@ -23,11 +23,20 @@ import { ShareFixDialog } from './ShareFixDialog'
  */
  */
 export function ShareFixCard({
 export function ShareFixCard({
   code,
   code,
+  label = code,
+  meaning = null,
+  makes = null,
   vehicle,
   vehicle,
   own: initialOwn,
   own: initialOwn,
   available,
   available,
 }: {
 }: {
   code: string
   code: string
+  /** The code as people read it ("BMW 283700"); the code itself otherwise. */
+  label?: string
+  /** For a make's own code, the meaning on screen: fixes are shared for it and listed by it. */
+  meaning?: { id: string; title: string } | null
+  /** The makes the code is on, for the share dialog's make. */
+  makes?: string[] | null
   vehicle: CodeVehicle | null
   vehicle: CodeVehicle | null
   own: OwnFix[]
   own: OwnFix[]
   /** The service answered when the page was read. */
   /** The service answered when the page was read. */
@@ -36,7 +45,11 @@ export function ShareFixCard({
   const t = useTranslations('faultCodes.share')
   const t = useTranslations('faultCodes.share')
   const confirm = useConfirm()
   const confirm = useConfirm()
   const { formatDate } = useFormatDate()
   const { formatDate } = useFormatDate()
-  const [own, setOwn] = useState<OwnFix[]>(initialOwn)
+  const [allOwn, setOwn] = useState<OwnFix[]>(initialOwn)
+  // This workshop's fixes for the meaning on screen; one for the code as a whole shows under each.
+  const own = meaning
+    ? allOwn.filter((f) => f.meaning === null || f.meaning === meaning.id)
+    : allOwn
   const [open, setOpen] = useState(false)
   const [open, setOpen] = useState(false)
   const [withdrawing, setWithdrawing] = useState<string | null>(null)
   const [withdrawing, setWithdrawing] = useState<string | null>(null)
 
 
@@ -69,7 +82,7 @@ export function ShareFixCard({
     <AppCard
     <AppCard
       icon={Share2}
       icon={Share2}
       title={t('title')}
       title={t('title')}
-      description={t('description', { code })}
+      description={t('description', { code: label })}
       action={
       action={
         available && own.length > 0 ? (
         available && own.length > 0 ? (
           <Button type="button" size="sm" variant="outline" onClick={() => setOpen(true)}>
           <Button type="button" size="sm" variant="outline" onClick={() => setOpen(true)}>
@@ -88,7 +101,7 @@ export function ShareFixCard({
             <Wrench className="size-5" aria-hidden />
             <Wrench className="size-5" aria-hidden />
           </span>
           </span>
           <div className="min-w-0 flex-1 space-y-1">
           <div className="min-w-0 flex-1 space-y-1">
-            <p className="font-medium">{t('invite', { code })}</p>
+            <p className="font-medium">{t('invite', { code: label })}</p>
             <p className="text-sm text-muted-foreground">{t('inviteHint')}</p>
             <p className="text-sm text-muted-foreground">{t('inviteHint')}</p>
           </div>
           </div>
           <Button type="button" onClick={() => setOpen(true)} className="shrink-0">
           <Button type="button" onClick={() => setOpen(true)} className="shrink-0">
@@ -99,7 +112,7 @@ export function ShareFixCard({
       ) : null}
       ) : null}
 
 
       {own.length > 0 && (
       {own.length > 0 && (
-        <ul className="space-y-3" aria-label={t('yourFixes', { code })}>
+        <ul className="space-y-3" aria-label={t('yourFixes', { code: label })}>
           {own.map((fix) => {
           {own.map((fix) => {
             const line = fixVehicleLine(fix.vehicle)
             const line = fixVehicleLine(fix.vehicle)
             return (
             return (
@@ -154,6 +167,9 @@ export function ShareFixCard({
           open={open}
           open={open}
           onOpenChange={setOpen}
           onOpenChange={setOpen}
           code={code}
           code={code}
+          label={label}
+          meaning={meaning}
+          makes={makes}
           vehicle={vehicle}
           vehicle={vehicle}
           onShared={(fix) => setOwn((current) => [fix, ...current])}
           onShared={(fix) => setOwn((current) => [fix, ...current])}
         />
         />

+ 74 - 122
src/features/fault-codes/Components/ShareFixDialog.tsx

@@ -2,9 +2,9 @@
 
 
 import { useEffect, useRef, useState } from 'react'
 import { useEffect, useRef, useState } from 'react'
 import { useLocale, useTranslations } from 'next-intl'
 import { useLocale, useTranslations } from 'next-intl'
-import { QRCodeSVG } from 'qrcode.react'
-import { Check, Copy, ImagePlus, Loader2, Send, Smartphone, X } from 'lucide-react'
+import { ImagePlus, Loader2, Send, Smartphone, X } from 'lucide-react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
+import { track } from '@/lib/analytics'
 import { useServiceType } from '@/components/service-type-context'
 import { useServiceType } from '@/components/service-type-context'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import {
 import {
@@ -19,12 +19,15 @@ import {
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { compressImage } from '@/lib/compress-image'
 import { compressImage } from '@/lib/compress-image'
-import { useFormatDate } from '@/lib/use-format-date'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import { RichTextEditor } from '@/features/vehicles/Components/service-edit/RichTextEditor'
 import { RichTextEditor } from '@/features/vehicles/Components/service-edit/RichTextEditor'
-import { createFixPhoneLink } from '../Actions/createFixPhoneLink'
-import { listFixDraftPhotos } from '../Actions/listFixDraftPhotos'
-import { removeFixDraftPhoto } from '../Actions/removeFixDraftPhoto'
 import { htmlToFixMarkdown } from '../Lib/fixMarkdown'
 import { htmlToFixMarkdown } from '../Lib/fixMarkdown'
 import type { CodeVehicle, OwnFix } from '../Lib/types'
 import type { CodeVehicle, OwnFix } from '../Lib/types'
 import {
 import {
@@ -34,25 +37,15 @@ import {
   MAX_FIX_TEXT,
   MAX_FIX_TEXT,
 } from '../Schema/faultCodeSchema'
 } from '../Schema/faultCodeSchema'
 import { fixVehicleLine } from './FixPhotos'
 import { fixVehicleLine } from './FixPhotos'
+import { PhoneCodePanel, usePhoneDraft, uuid } from './PhoneCode'
 
 
 const SHARE_URL = '/api/protected/fault-codes/fixes'
 const SHARE_URL = '/api/protected/fault-codes/fixes'
-const POLL_MS = 2500
 
 
 type Picked = { file: File; url: string }
 type Picked = { file: File; url: string }
 type ShareReply = { success: true; data: OwnFix } | { success: false; error?: string }
 type ShareReply = { success: true; data: OwnFix } | { success: false; error?: string }
 const FAILURES = ['photo', 'too_large', 'paused', 'demo'] as const
 const FAILURES = ['photo', 'too_large', 'paused', 'demo'] as const
 type Failure = (typeof FAILURES)[number]
 type Failure = (typeof FAILURES)[number]
 
 
-/** A v4 UUID, also where `crypto.randomUUID` is missing (plain http on a LAN). */
-function uuid(): string {
-  if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
-  const b = crypto.getRandomValues(new Uint8Array(16))
-  b[6] = (b[6] & 0x0f) | 0x40
-  b[8] = (b[8] & 0x3f) | 0x80
-  const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
-  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
-}
-
 /**
 /**
  * Writing a fix for other workshops: what fixed the code in the
  * Writing a fix for other workshops: what fixed the code in the
  * technician's own words, with the notes editor, and up to four photos,
  * technician's own words, with the notes editor, and up to four photos,
@@ -64,28 +57,40 @@ export function ShareFixDialog({
   open,
   open,
   onOpenChange,
   onOpenChange,
   code,
   code,
+  label = code,
+  meaning = null,
+  makes = null,
   vehicle,
   vehicle,
   onShared,
   onShared,
 }: {
 }: {
   open: boolean
   open: boolean
   onOpenChange: (open: boolean) => void
   onOpenChange: (open: boolean) => void
   code: string
   code: string
+  /** The code as people read it ("BMW 283700"); the code itself otherwise. */
+  label?: string
+  /** For a make's own code, the meaning the fix is for, which goes with it. */
+  meaning?: { id: string; title: string } | null
+  /** The makes the code is on (BMW, MINI, Rolls-Royce): the make is picked from them, the first first. */
+  makes?: string[] | null
   vehicle: CodeVehicle | null
   vehicle: CodeVehicle | null
   onShared: (fix: OwnFix) => void
   onShared: (fix: OwnFix) => void
 }) {
 }) {
   const t = useTranslations('faultCodes.share')
   const t = useTranslations('faultCodes.share')
   const locale = useLocale()
   const locale = useLocale()
   const isMarine = useServiceType() === 'marine'
   const isMarine = useServiceType() === 'marine'
-  const { formatTime } = useFormatDate()
   const [html, setHtml] = useState('')
   const [html, setHtml] = useState('')
   const [picked, setPicked] = useState<Picked[]>([])
   const [picked, setPicked] = useState<Picked[]>([])
-  const [phone, setPhone] = useState<{ id: string; url: string }[]>([])
   const [draftId, setDraftId] = useState(uuid)
   const [draftId, setDraftId] = useState(uuid)
   const [phoneOpen, setPhoneOpen] = useState(false)
   const [phoneOpen, setPhoneOpen] = useState(false)
-  const [link, setLink] = useState<{ url: string; expiresAt: Date } | null>(null)
-  const [linkFailed, setLinkFailed] = useState(false)
-  const [copied, setCopied] = useState(false)
-  const [make, setMake] = useState('')
+  const {
+    link,
+    failed: linkFailed,
+    photos: phone,
+    setPhotos: setPhone,
+    remove: removePhone,
+  } = usePhoneDraft({ active: open && phoneOpen, code, draftId, purpose: 'fix' })
+  const defaultMake = makes?.[0] ?? ''
+  const [make, setMake] = useState(defaultMake)
   const [model, setModel] = useState('')
   const [model, setModel] = useState('')
   const [year, setYear] = useState('')
   const [year, setYear] = useState('')
   const [adding, setAdding] = useState(false)
   const [adding, setAdding] = useState(false)
@@ -107,50 +112,6 @@ export function ShareFixDialog({
   const tooManyPhotos = photoCount > MAX_FIX_PHOTOS
   const tooManyPhotos = photoCount > MAX_FIX_PHOTOS
   const room = Math.max(0, MAX_FIX_PHOTOS - photoCount)
   const room = Math.max(0, MAX_FIX_PHOTOS - photoCount)
 
 
-  // A fresh phone code each time the panel opens; the last may have lapsed.
-  useEffect(() => {
-    if (!open || !phoneOpen) return
-    let cancelled = false
-    setLink(null)
-    setLinkFailed(false)
-    createFixPhoneLink({ code, draftId })
-      .then((result) => {
-        if (cancelled) return
-        if (!result.success || !result.data) {
-          setLinkFailed(true)
-          return
-        }
-        setLink({
-          url: `${window.location.origin}/p/${result.data.token}`,
-          expiresAt: new Date(result.data.expiresAt),
-        })
-      })
-      .catch(() => {
-        if (!cancelled) setLinkFailed(true)
-      })
-    return () => {
-      cancelled = true
-    }
-  }, [open, phoneOpen, code, draftId])
-
-  // Phone photos show here as they land, while the code is up.
-  useEffect(() => {
-    if (!open || !phoneOpen) return
-    const read = async () => {
-      const result = await listFixDraftPhotos({ draftId }).catch(() => null)
-      if (result?.success && result.data) setPhone(result.data)
-    }
-    void read()
-    const timer = setInterval(read, POLL_MS)
-    return () => clearInterval(timer)
-  }, [open, phoneOpen, draftId])
-
-  useEffect(() => {
-    if (!copied) return
-    const timer = setTimeout(() => setCopied(false), 1500)
-    return () => clearTimeout(timer)
-  }, [copied])
-
   const addPhotos = async (list: FileList | File[] | null) => {
   const addPhotos = async (list: FileList | File[] | null) => {
     if (!list || list.length === 0) return
     if (!list || list.length === 0) return
     const files = Array.from(list)
     const files = Array.from(list)
@@ -186,11 +147,6 @@ export function ShareFixDialog({
     setPicked((current) => current.filter((p) => p.url !== url))
     setPicked((current) => current.filter((p) => p.url !== url))
   }
   }
 
 
-  const removePhone = async (photoId: string) => {
-    setPhone((current) => current.filter((p) => p.id !== photoId))
-    await removeFixDraftPhoto({ draftId, photoId }).catch(() => null)
-  }
-
   const reset = () => {
   const reset = () => {
     for (const p of picked) URL.revokeObjectURL(p.url)
     for (const p of picked) URL.revokeObjectURL(p.url)
     setHtml('')
     setHtml('')
@@ -198,7 +154,7 @@ export function ShareFixDialog({
     setPhone([])
     setPhone([])
     setPhoneOpen(false)
     setPhoneOpen(false)
     setDraftId(uuid())
     setDraftId(uuid())
-    setMake('')
+    setMake(defaultMake)
     setModel('')
     setModel('')
     setYear('')
     setYear('')
   }
   }
@@ -208,6 +164,7 @@ export function ShareFixDialog({
     setSending(true)
     setSending(true)
     const form = new FormData()
     const form = new FormData()
     form.append('code', code)
     form.append('code', code)
+    if (meaning) form.append('meaning', meaning.id)
     form.append('text', markdown)
     form.append('text', markdown)
     form.append('lang', locale)
     form.append('lang', locale)
     if (phone.length > 0) form.append('draftId', draftId)
     if (phone.length > 0) form.append('draftId', draftId)
@@ -228,6 +185,11 @@ export function ShareFixDialog({
     }
     }
     setSending(false)
     setSending(false)
     if (reply?.success) {
     if (reply?.success) {
+      track('fault_code:fix_share', {
+        code,
+        photos: picked.length + phone.length,
+        from_phone: phone.length > 0,
+      })
       onShared(reply.data)
       onShared(reply.data)
       reset()
       reset()
       onOpenChange(false)
       onOpenChange(false)
@@ -246,11 +208,21 @@ export function ShareFixDialog({
     <Dialog open={open} onOpenChange={onOpenChange}>
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent className="sm:max-w-3xl">
       <DialogContent className="sm:max-w-3xl">
         <DialogHeader>
         <DialogHeader>
-          <DialogTitle>{t('dialogTitle', { code })}</DialogTitle>
+          <DialogTitle>{t('dialogTitle', { code: label })}</DialogTitle>
           <DialogDescription>{t('dialogDescription')}</DialogDescription>
           <DialogDescription>{t('dialogDescription')}</DialogDescription>
         </DialogHeader>
         </DialogHeader>
 
 
         <DialogBody className="space-y-6">
         <DialogBody className="space-y-6">
+          {/* A make's code means one thing per control unit: the fix is for the one on screen. */}
+          {meaning && (
+            <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-card-edge bg-muted/30 px-4 py-3">
+              <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+                {t('meaning')}
+              </span>
+              <span className="text-sm font-medium">{meaning.title}</span>
+              <span className="w-full text-xs text-muted-foreground">{t('meaningHint')}</span>
+            </div>
+          )}
           {/* The vehicle: from the job, or typed, all of it optional. */}
           {/* The vehicle: from the job, or typed, all of it optional. */}
           {vehicle ? (
           {vehicle ? (
             <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-card-edge bg-muted/30 px-4 py-3">
             <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-card-edge bg-muted/30 px-4 py-3">
@@ -272,12 +244,27 @@ export function ShareFixDialog({
                   <Label htmlFor={`fix-make-${code}`} className="text-xs text-muted-foreground">
                   <Label htmlFor={`fix-make-${code}`} className="text-xs text-muted-foreground">
                     {t('make')}
                     {t('make')}
                   </Label>
                   </Label>
-                  <Input
-                    id={`fix-make-${code}`}
-                    value={make}
-                    maxLength={60}
-                    onChange={(e) => setMake(e.target.value)}
-                  />
+                  {makes ? (
+                    <Select value={make} onValueChange={setMake}>
+                      <SelectTrigger id={`fix-make-${code}`} className="w-full">
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        {makes.map((m) => (
+                          <SelectItem key={m} value={m}>
+                            {m}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  ) : (
+                    <Input
+                      id={`fix-make-${code}`}
+                      value={make}
+                      maxLength={60}
+                      onChange={(e) => setMake(e.target.value)}
+                    />
+                  )}
                 </div>
                 </div>
                 <div className="space-y-1.5">
                 <div className="space-y-1.5">
                   <Label htmlFor={`fix-model-${code}`} className="text-xs text-muted-foreground">
                   <Label htmlFor={`fix-model-${code}`} className="text-xs text-muted-foreground">
@@ -359,48 +346,13 @@ export function ShareFixDialog({
             </div>
             </div>
 
 
             {phoneOpen && (
             {phoneOpen && (
-              <div className="flex flex-col items-center gap-4 rounded-lg border border-card-edge bg-muted/30 p-4 sm:flex-row sm:items-start">
-                <div className="flex size-[164px] shrink-0 items-center justify-center rounded-lg bg-white p-2 shadow-sm">
-                  {link ? (
-                    <QRCodeSVG value={link.url} size={148} marginSize={0} />
-                  ) : linkFailed ? (
-                    <X className="size-6 text-muted-foreground" aria-hidden />
-                  ) : (
-                    <Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
-                  )}
-                </div>
-                <div className="min-w-0 flex-1 space-y-2 text-center sm:text-left">
-                  <p className="text-sm font-medium">{t('phone.title')}</p>
-                  <p className="text-sm text-muted-foreground">
-                    {linkFailed ? t('phone.failed') : t('phone.body')}
-                  </p>
-                  {link && (
-                    <>
-                      <p className="text-xs text-muted-foreground">
-                        {t('phone.expires', { time: formatTime(link.expiresAt) })}
-                      </p>
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="xs"
-                        className="text-muted-foreground"
-                        onClick={() => {
-                          void navigator.clipboard?.writeText(link.url)
-                          setCopied(true)
-                        }}
-                      >
-                        {copied ? <Check /> : <Copy />}
-                        {copied ? t('phone.copied') : t('phone.copy')}
-                      </Button>
-                    </>
-                  )}
-                  {phone.length > 0 && (
-                    <p className="text-xs font-medium text-primary" aria-live="polite">
-                      {t('phone.received', { count: phone.length })}
-                    </p>
-                  )}
-                </div>
-              </div>
+              <PhoneCodePanel
+                link={link}
+                failed={linkFailed}
+                title={t('phone.title')}
+                body={t('phone.body')}
+                received={phone.length > 0 ? t('phone.received', { count: phone.length }) : null}
+              />
             )}
             )}
 
 
             <div
             <div

+ 679 - 0
src/features/fault-codes/Components/ShareWaveformDialog.tsx

@@ -0,0 +1,679 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import {
+  CircleCheck,
+  FileSpreadsheet,
+  ImageIcon,
+  Loader2,
+  Settings2,
+  Smartphone,
+  TriangleAlert,
+  Upload,
+  Waves,
+  X,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { track } from '@/lib/analytics'
+import { useServiceType } from '@/components/service-type-context'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { cn } from '@/lib/utils'
+import { formatBytes } from '@/features/integrations/Lib/format-bytes'
+import type {
+  FaultCodeOwnWaveform,
+  WaveformCondition,
+  WaveformFileRole,
+  WaveformKind,
+  WaveformPreview,
+} from '@/features/integrations/Lib/types'
+import type { CodeVehicle } from '../Lib/types'
+import { previewCsv, waveformRole } from '../Lib/waveformCsv'
+import {
+  MAX_FIX_TEXT,
+  MAX_WAVEFORM_IMAGE_BYTES,
+  MAX_WAVEFORM_RECORDING_BYTES,
+  MAX_WAVEFORM_SETTINGS_BYTES,
+  WAVEFORM_CONDITIONS,
+} from '../Schema/faultCodeSchema'
+import { fixVehicleLine } from './FixPhotos'
+import { PhoneCodePanel, usePhoneDraft, uuid } from './PhoneCode'
+import { WaveformChart } from './WaveformChart'
+import { useConditionLabel } from './WaveformParts'
+
+const SHARE_URL = '/api/protected/fault-codes/waveforms'
+const ROLES: WaveformFileRole[] = ['psdata', 'csv', 'image', 'pssettings']
+const LIMITS: Record<WaveformFileRole, number> = {
+  psdata: MAX_WAVEFORM_RECORDING_BYTES,
+  csv: MAX_WAVEFORM_RECORDING_BYTES,
+  pssettings: MAX_WAVEFORM_SETTINGS_BYTES,
+  image: MAX_WAVEFORM_IMAGE_BYTES,
+}
+const ICONS: Record<WaveformFileRole, typeof Waves> = {
+  psdata: Waves,
+  pssettings: Settings2,
+  csv: FileSpreadsheet,
+  image: ImageIcon,
+}
+/** What technicians measure most, offered as a tap; the words are theirs to change. */
+const MEASUREMENTS = [
+  'crank',
+  'camCrank',
+  'injector',
+  'ignition',
+  'compression',
+  'lambda',
+  'maf',
+  'alternator',
+  'canBus',
+  'fuelPump',
+] as const
+
+type Picked = { file: File; url?: string }
+type ShareReply = { success: true; data: FaultCodeOwnWaveform } | { success: false; error?: string }
+const FAILURES = ['photo', 'csv', 'too_large', 'no_file', 'paused', 'demo'] as const
+type Failure = (typeof FAILURES)[number]
+
+/** A form sent with its upload progress, which a fetch cannot tell. */
+function upload(form: FormData, onProgress: (share: number) => void): Promise<ShareReply | null> {
+  return new Promise((resolve) => {
+    const xhr = new XMLHttpRequest()
+    xhr.open('POST', SHARE_URL)
+    xhr.upload.onprogress = (e) => {
+      if (e.lengthComputable) onProgress(e.loaded / e.total)
+    }
+    xhr.onload = () => {
+      try {
+        resolve(JSON.parse(xhr.responseText) as ShareReply)
+      } catch {
+        resolve(null)
+      }
+    }
+    xhr.onerror = () => resolve(null)
+    xhr.send(form)
+  })
+}
+
+/**
+ * Sharing a scope recording for a code, made for a technician at the bench rather than at a
+ * desk: known good or faulty as two big buttons, what was measured from a tap, how the
+ * engine ran, and one place to drop whatever files they have. Each file is sorted by its
+ * kind as it lands; a CSV export is drawn at once, so they see what others will see. A photo
+ * of the scope screen can come from their phone through a code. What was filled in stays
+ * when the dialog is closed, until it is shared.
+ */
+export function ShareWaveformDialog({
+  open,
+  onOpenChange,
+  code,
+  label = code,
+  meaning = null,
+  makes = null,
+  vehicle,
+  onShared,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  code: string
+  label?: string
+  meaning?: { id: string; title: string } | null
+  makes?: string[] | null
+  vehicle: CodeVehicle | null
+  onShared: (waveform: FaultCodeOwnWaveform) => void
+}) {
+  const t = useTranslations('faultCodes.waveforms.shareDialog')
+  const tw = useTranslations('faultCodes.waveforms')
+  const ts = useTranslations('faultCodes.share')
+  const locale = useLocale()
+  const isMarine = useServiceType() === 'marine'
+  const conditionLabel = useConditionLabel()
+  const [kind, setKind] = useState<WaveformKind | null>(null)
+  const [title, setTitle] = useState('')
+  const [condition, setCondition] = useState<WaveformCondition | null>(null)
+  const [note, setNote] = useState('')
+  const [files, setFiles] = useState<Partial<Record<WaveformFileRole, Picked>>>({})
+  const [preview, setPreview] = useState<WaveformPreview | null>(null)
+  const [csvBad, setCsvBad] = useState(false)
+  const defaultMake = makes?.[0] ?? ''
+  const [make, setMake] = useState(defaultMake)
+  const [model, setModel] = useState('')
+  const [year, setYear] = useState('')
+  const [draftId, setDraftId] = useState(uuid)
+  const [phoneOpen, setPhoneOpen] = useState(false)
+  const phone = usePhoneDraft({
+    active: open && phoneOpen,
+    code,
+    draftId,
+    purpose: 'waveform',
+  })
+  const [dragging, setDragging] = useState(false)
+  const [progress, setProgress] = useState<number | null>(null)
+  const fileInput = useRef<HTMLInputElement>(null)
+
+  const phonePhoto = files.image ? null : (phone.photos[0] ?? null)
+  const hasLookable = Boolean(files.psdata || files.csv || files.image || phonePhoto)
+  const tooLong = note.length > MAX_FIX_TEXT
+  const sending = progress !== null
+  const missing = !kind
+    ? t('missing.kind')
+    : !title.trim()
+      ? t('missing.title')
+      : !hasLookable
+        ? t('missing.file')
+        : csvBad
+          ? t('missing.csv')
+          : null
+
+  const addFiles = async (list: FileList | File[] | null) => {
+    if (!list || list.length === 0) return
+    const next = { ...files }
+    for (const file of Array.from(list)) {
+      const role = waveformRole(file.name, file.type)
+      if (!role) {
+        toast.error(t('notAScopeFile', { name: file.name }))
+        continue
+      }
+      if (file.size > LIMITS[role]) {
+        toast.error(t('fileTooBig', { name: file.name, size: formatBytes(LIMITS[role]) }))
+        continue
+      }
+      if (next[role]?.url) URL.revokeObjectURL(next[role].url as string)
+      next[role] = { file, url: role === 'image' ? URL.createObjectURL(file) : undefined }
+      if (role === 'csv') {
+        const drawn = previewCsv(await file.text().catch(() => ''))
+        setPreview(drawn)
+        setCsvBad(!drawn)
+      }
+    }
+    setFiles(next)
+    if (fileInput.current) fileInput.current.value = ''
+  }
+
+  const removeFile = (role: WaveformFileRole) => {
+    const url = files[role]?.url
+    if (url) URL.revokeObjectURL(url)
+    setFiles(({ [role]: _gone, ...rest }) => rest)
+    if (role === 'csv') {
+      setPreview(null)
+      setCsvBad(false)
+    }
+  }
+
+  const reset = () => {
+    for (const p of Object.values(files)) if (p?.url) URL.revokeObjectURL(p.url)
+    setKind(null)
+    setTitle('')
+    setCondition(null)
+    setNote('')
+    setFiles({})
+    setPreview(null)
+    setCsvBad(false)
+    setMake(defaultMake)
+    setModel('')
+    setYear('')
+    setPhoneOpen(false)
+    phone.setPhotos([])
+    setDraftId(uuid())
+  }
+
+  const send = async () => {
+    if (missing || tooLong || sending || !kind) return
+    const form = new FormData()
+    form.append('code', code)
+    if (meaning) form.append('meaning', meaning.id)
+    form.append('kind', kind)
+    form.append('title', title.trim())
+    if (condition) form.append('condition', condition)
+    if (note.trim()) form.append('note', note.trim())
+    form.append('lang', locale)
+    if (phonePhoto) form.append('draftId', draftId)
+    if (vehicle) {
+      form.append('vehicleId', vehicle.id)
+    } else {
+      if (make.trim()) form.append('make', make.trim())
+      if (model.trim()) form.append('model', model.trim())
+      if (year.trim()) form.append('year', year.trim())
+    }
+    for (const role of ROLES) {
+      const picked = files[role]
+      if (picked) form.append(role, picked.file, picked.file.name)
+    }
+    setProgress(0)
+    const reply = await upload(form, setProgress)
+    setProgress(null)
+    if (reply?.success) {
+      track('fault_code:waveform_share', {
+        code,
+        kind,
+        condition: condition ?? null,
+        psdata: Boolean(files.psdata),
+        csv: Boolean(files.csv),
+        image: Boolean(files.image || phonePhoto),
+        from_phone: Boolean(phonePhoto),
+      })
+      onShared(reply.data)
+      reset()
+      onOpenChange(false)
+      toast.success(t('sent'))
+      return
+    }
+    // What was filled in stays, ready to try again.
+    const error = reply && !reply.success ? reply.error : undefined
+    const failure = FAILURES.find((f) => f === error) as Failure | undefined
+    toast.error(failure ? t(`errors.${failure}`) : t('errors.failed'))
+  }
+
+  const listed = ROLES.filter((r) => files[r])
+
+  return (
+    <Dialog open={open} onOpenChange={(next) => !sending && onOpenChange(next)}>
+      <DialogContent className="sm:max-w-3xl">
+        <DialogHeader>
+          <DialogTitle>{t('title', { code: label })}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+
+        <DialogBody className="space-y-6">
+          {meaning && (
+            <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-card-edge bg-muted/30 px-4 py-3">
+              <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+                {ts('meaning')}
+              </span>
+              <span className="text-sm font-medium">{meaning.title}</span>
+              <span className="w-full text-xs text-muted-foreground">{t('meaningHint')}</span>
+            </div>
+          )}
+
+          {/* Known good or faulty: the first thing anyone looking for one asks. */}
+          <fieldset className="space-y-2">
+            <legend className="mb-2 text-sm font-medium">{t('kind')}</legend>
+            <div className="grid gap-3 sm:grid-cols-2">
+              {(['good', 'faulty'] as const).map((k) => {
+                const on = kind === k
+                const Icon = k === 'good' ? CircleCheck : TriangleAlert
+                return (
+                  <button
+                    key={k}
+                    type="button"
+                    aria-pressed={on}
+                    onClick={() => setKind(k)}
+                    className={cn(
+                      'flex items-start gap-3 rounded-lg border-2 p-4 text-left transition-colors',
+                      on
+                        ? k === 'good'
+                          ? 'border-emerald-500/70 bg-emerald-500/10'
+                          : 'border-amber-500/70 bg-amber-500/10'
+                        : 'border-border hover:border-primary/40 hover:bg-muted/40'
+                    )}
+                  >
+                    <Icon
+                      className={cn(
+                        'mt-0.5 size-5 shrink-0',
+                        k === 'good'
+                          ? 'text-emerald-600 dark:text-emerald-400'
+                          : 'text-amber-600 dark:text-amber-400'
+                      )}
+                      aria-hidden
+                    />
+                    <span className="space-y-0.5">
+                      <span className="block font-medium">{tw(`kind.${k}`)}</span>
+                      <span className="block text-sm text-muted-foreground">
+                        {isMarine ? t(`kindHint.${k}Marine`) : t(`kindHint.${k}`)}
+                      </span>
+                    </span>
+                  </button>
+                )
+              })}
+            </div>
+          </fieldset>
+
+          {/* What was measured: a tap fills it, the words stay theirs. */}
+          <div className="space-y-2">
+            <Label htmlFor={`wave-title-${code}`} className="text-sm font-medium">
+              {t('measured')}
+            </Label>
+            <Input
+              id={`wave-title-${code}`}
+              value={title}
+              maxLength={120}
+              placeholder={t('measuredPlaceholder')}
+              onChange={(e) => setTitle(e.target.value)}
+            />
+            <div className="flex flex-wrap gap-1.5">
+              {MEASUREMENTS.map((m) => {
+                const words = t(`measurements.${m}`)
+                return (
+                  <button
+                    key={m}
+                    type="button"
+                    onClick={() => setTitle(words)}
+                    className={cn(
+                      'rounded-full border px-2.5 py-1 text-xs transition-colors hover:border-primary/50 hover:bg-primary/5',
+                      title === words
+                        ? 'border-primary/60 bg-primary/10 text-foreground'
+                        : 'border-border text-muted-foreground'
+                    )}
+                  >
+                    {words}
+                  </button>
+                )
+              })}
+            </div>
+          </div>
+
+          {/* How the engine ran: optional, one tap, tap again to clear. */}
+          <div className="space-y-2">
+            <span className="text-sm font-medium">{t('condition')}</span>
+            <div className="flex flex-wrap gap-1.5" role="group" aria-label={t('condition')}>
+              {WAVEFORM_CONDITIONS.map((c) => (
+                <button
+                  key={c}
+                  type="button"
+                  aria-pressed={condition === c}
+                  onClick={() => setCondition((now) => (now === c ? null : c))}
+                  className={cn(
+                    'rounded-md border px-3 py-1.5 text-sm transition-colors',
+                    condition === c
+                      ? 'border-primary bg-primary text-primary-foreground'
+                      : 'border-border hover:border-primary/40 hover:bg-muted/40'
+                  )}
+                >
+                  {conditionLabel(c)}
+                </button>
+              ))}
+            </div>
+          </div>
+
+          {/* Every file in one place: each is sorted by what it is as it lands. */}
+          <div className="space-y-3">
+            <div className="flex flex-wrap items-end justify-between gap-2">
+              <div className="space-y-0.5">
+                <span className="text-sm font-medium">{t('files')}</span>
+                <p className="text-xs text-muted-foreground">{t('filesHint')}</p>
+              </div>
+              <Button
+                type="button"
+                variant={phoneOpen ? 'secondary' : 'outline'}
+                size="sm"
+                onClick={() => setPhoneOpen((v) => !v)}
+                aria-expanded={phoneOpen}
+              >
+                <Smartphone />
+                {phoneOpen ? ts('phone.hide') : t('phone.button')}
+              </Button>
+            </div>
+
+            {phoneOpen && (
+              <PhoneCodePanel
+                link={phone.link}
+                failed={phone.failed}
+                title={t('phone.title')}
+                body={t('phone.body')}
+                received={phone.photos.length > 0 ? t('phone.received') : null}
+              />
+            )}
+
+            <button
+              type="button"
+              onClick={() => fileInput.current?.click()}
+              onDragOver={(e) => {
+                if (!e.dataTransfer.types.includes('Files')) return
+                e.preventDefault()
+                setDragging(true)
+              }}
+              onDragLeave={() => setDragging(false)}
+              onDrop={(e) => {
+                e.preventDefault()
+                setDragging(false)
+                void addFiles(e.dataTransfer.files)
+              }}
+              className={cn(
+                'flex w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed border-border bg-muted/20 px-4 text-center text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground',
+                listed.length === 0 && !phonePhoto ? 'py-9' : 'py-4',
+                dragging && 'border-primary/60 bg-primary/5'
+              )}
+            >
+              <Upload className="size-5" aria-hidden />
+              <span className="text-sm font-medium text-foreground">
+                {listed.length === 0 && !phonePhoto ? t('drop') : t('dropMore')}
+              </span>
+              <span>{t('dropTypes')}</span>
+            </button>
+            <input
+              ref={fileInput}
+              type="file"
+              accept=".psdata,.pssettings,.csv,.txt,image/*"
+              multiple
+              hidden
+              data-testid="waveform-file-input"
+              onChange={(e) => void addFiles(e.target.files)}
+            />
+
+            {(listed.length > 0 || phonePhoto) && (
+              <ul className="divide-y divide-card-edge/60 rounded-lg border border-card-edge">
+                {listed.map((role) => {
+                  const picked = files[role] as Picked
+                  const Icon = ICONS[role]
+                  return (
+                    <li key={role} className="flex items-center gap-3 px-3 py-2.5">
+                      {picked.url ? (
+                        <img
+                          src={picked.url}
+                          alt=""
+                          className="size-9 shrink-0 rounded border border-card-edge object-cover"
+                        />
+                      ) : (
+                        <span className="flex size-9 shrink-0 items-center justify-center rounded bg-primary/10 text-primary">
+                          <Icon className="size-4" aria-hidden />
+                        </span>
+                      )}
+                      <span className="min-w-0 flex-1">
+                        <span className="block text-sm font-medium">{t(`role.${role}`)}</span>
+                        <span className="block truncate text-xs text-muted-foreground">
+                          {picked.file.name} · {formatBytes(picked.file.size)}
+                        </span>
+                        {role === 'csv' && csvBad && (
+                          <span className="block text-xs font-medium text-destructive">
+                            {t('csvNotScope')}
+                          </span>
+                        )}
+                      </span>
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="icon-sm"
+                        className="text-muted-foreground"
+                        aria-label={t('remove', { name: picked.file.name })}
+                        onClick={() => removeFile(role)}
+                      >
+                        <X />
+                      </Button>
+                    </li>
+                  )
+                })}
+                {phonePhoto && (
+                  <li className="flex items-center gap-3 px-3 py-2.5">
+                    <img
+                      src={phonePhoto.url}
+                      alt=""
+                      className="size-9 shrink-0 rounded border border-card-edge object-cover"
+                    />
+                    <span className="min-w-0 flex-1">
+                      <span className="block text-sm font-medium">{t('role.image')}</span>
+                      <span className="block text-xs text-muted-foreground">{t('fromPhone')}</span>
+                    </span>
+                    <Button
+                      type="button"
+                      variant="ghost"
+                      size="icon-sm"
+                      className="text-muted-foreground"
+                      aria-label={t('remove', { name: t('role.image') })}
+                      onClick={() => void phone.remove(phonePhoto.id)}
+                    >
+                      <X />
+                    </Button>
+                  </li>
+                )}
+              </ul>
+            )}
+
+            {preview && (
+              <div className="space-y-1.5">
+                <p className="text-xs text-muted-foreground">{t('previewHint')}</p>
+                <WaveformChart preview={preview} />
+              </div>
+            )}
+            {files.psdata && !files.csv && (
+              <p className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
+                {t('csvTip')}
+              </p>
+            )}
+          </div>
+
+          {/* The car: from the job, or typed, all of it optional. */}
+          {vehicle ? (
+            <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-card-edge bg-muted/30 px-4 py-3">
+              <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+                {isMarine ? ts('vehicleMarine') : ts('vehicle')}
+              </span>
+              <span className="text-sm font-medium">{fixVehicleLine(vehicle)}</span>
+              <span className="w-full text-xs text-muted-foreground">
+                {isMarine ? ts('vehicleHintMarine') : ts('vehicleHint')}
+              </span>
+            </div>
+          ) : (
+            <fieldset className="space-y-2">
+              <legend className="text-sm font-medium">
+                {isMarine ? ts('vehicleOptionalMarine') : ts('vehicleOptional')}
+              </legend>
+              <div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7rem]">
+                <div className="space-y-1.5">
+                  <Label htmlFor={`wave-make-${code}`} className="text-xs text-muted-foreground">
+                    {ts('make')}
+                  </Label>
+                  {makes ? (
+                    <Select value={make} onValueChange={setMake}>
+                      <SelectTrigger id={`wave-make-${code}`} className="w-full">
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        {makes.map((m) => (
+                          <SelectItem key={m} value={m}>
+                            {m}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  ) : (
+                    <Input
+                      id={`wave-make-${code}`}
+                      value={make}
+                      maxLength={60}
+                      onChange={(e) => setMake(e.target.value)}
+                    />
+                  )}
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor={`wave-model-${code}`} className="text-xs text-muted-foreground">
+                    {ts('model')}
+                  </Label>
+                  <Input
+                    id={`wave-model-${code}`}
+                    value={model}
+                    maxLength={60}
+                    onChange={(e) => setModel(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-1.5">
+                  <Label htmlFor={`wave-year-${code}`} className="text-xs text-muted-foreground">
+                    {ts('year')}
+                  </Label>
+                  <Input
+                    id={`wave-year-${code}`}
+                    value={year}
+                    inputMode="numeric"
+                    maxLength={4}
+                    onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))}
+                  />
+                </div>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                {isMarine ? ts('vehicleHintMarine') : ts('vehicleHint')}
+              </p>
+            </fieldset>
+          )}
+
+          <div className="space-y-2">
+            <div className="flex items-baseline justify-between gap-3">
+              <Label htmlFor={`wave-note-${code}`} className="text-sm font-medium">
+                {t('note')}
+              </Label>
+              {note.length > MAX_FIX_TEXT * 0.8 && (
+                <span
+                  className={cn(
+                    'text-xs tabular-nums text-muted-foreground',
+                    tooLong && 'font-medium text-destructive'
+                  )}
+                >
+                  {ts('characters', { count: note.length, max: MAX_FIX_TEXT })}
+                </span>
+              )}
+            </div>
+            <Textarea
+              id={`wave-note-${code}`}
+              value={note}
+              rows={3}
+              placeholder={isMarine ? t('notePlaceholderMarine') : t('notePlaceholder')}
+              onChange={(e) => setNote(e.target.value)}
+            />
+          </div>
+        </DialogBody>
+
+        <DialogFooter className="items-center sm:justify-between">
+          <p className="text-xs text-muted-foreground" aria-live="polite">
+            {sending
+              ? t('sending', { percent: Math.round((progress ?? 0) * 100) })
+              : (missing ?? '')}
+          </p>
+          <div className="flex gap-2">
+            <Button
+              type="button"
+              variant="ghost"
+              disabled={sending}
+              onClick={() => onOpenChange(false)}
+            >
+              {ts('cancel')}
+            </Button>
+            <Button
+              type="button"
+              disabled={Boolean(missing) || tooLong || sending}
+              onClick={() => void send()}
+            >
+              {sending ? <Loader2 className="animate-spin" /> : <Upload />}
+              {t('send')}
+            </Button>
+          </div>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 267 - 0
src/features/fault-codes/Components/WaveformChart.tsx

@@ -0,0 +1,267 @@
+'use client'
+
+import { useId, useMemo, useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import type { WaveformPreview } from '@/features/integrations/Lib/types'
+
+/**
+ * PicoScope's channel colours, A to H, in tones that read on light and dark alike. A technician
+ * knows channel A as blue and B as red from the scope itself, so these follow the channel and
+ * not the theme.
+ */
+const CHANNEL_COLORS = [
+  '#3b82f6',
+  '#ef4444',
+  '#22c55e',
+  '#eab308',
+  '#a855f7',
+  '#94a3b8',
+  '#06b6d4',
+  '#ec4899',
+]
+const DIVISIONS_X = 10
+const DIVISIONS_Y = 8
+const WIDTH = 1000
+
+/** Channel A is blue whatever its place in the export; one with no letter goes by its place. */
+export function channelColor(name: string, index: number): string {
+  const letter = /(?:^|\s)([A-H])$/i.exec(name.trim())?.[1]
+  const at = letter ? letter.toUpperCase().charCodeAt(0) - 65 : index
+  return CHANNEL_COLORS[at % CHANNEL_COLORS.length]
+}
+
+type Range = { lo: number; hi: number }
+
+function rangeOf(channel: WaveformPreview['channels'][number]): Range | null {
+  let lo = Number.POSITIVE_INFINITY
+  let hi = Number.NEGATIVE_INFINITY
+  for (const v of channel.min) if (v !== null && v < lo) lo = v
+  for (const v of channel.max) if (v !== null && v > hi) hi = v
+  if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null
+  // A flat line sits in the middle rather than on an edge.
+  if (hi - lo < 1e-9) return { lo: lo - 1, hi: hi + 1 }
+  const pad = (hi - lo) * 0.08
+  return { lo: lo - pad, hi: hi + pad }
+}
+
+/**
+ * Each run of slices with samples as one shape: the highs left to right, the lows back, so a
+ * square wave's edges and a spike's top both show at any width.
+ */
+function bandPaths(
+  channel: WaveformPreview['channels'][number],
+  range: Range,
+  height: number
+): string[] {
+  const n = channel.min.length
+  const step = WIDTH / n
+  const y = (v: number) => height - ((v - range.lo) / (range.hi - range.lo)) * height
+  const paths: string[] = []
+  let run: number[] = []
+  const flush = () => {
+    if (run.length === 0) return
+    const top = run.map((i) => `${(i * step).toFixed(1)},${y(channel.max[i] as number).toFixed(1)}`)
+    const last = run[run.length - 1]
+    top.push(`${((last + 1) * step).toFixed(1)},${y(channel.max[last] as number).toFixed(1)}`)
+    const bottom = [...run]
+      .reverse()
+      .map((i) => `${(i * step).toFixed(1)},${y(channel.min[i] as number).toFixed(1)}`)
+    bottom.unshift(`${((last + 1) * step).toFixed(1)},${y(channel.min[last] as number).toFixed(1)}`)
+    paths.push(`M${top.join('L')}L${bottom.join('L')}Z`)
+    run = []
+  }
+  for (let i = 0; i < n; i++) {
+    if (channel.min[i] === null || channel.max[i] === null) flush()
+    else run.push(i)
+  }
+  flush()
+  return paths
+}
+
+/**
+ * A scope recording drawn from its CSV export, the way the scope shows it: a grid of
+ * divisions, each channel on its own scale in its own colour, the time across. `compact` is
+ * the small one in a list; the full one names the channels, the time per division and, under
+ * the pointer, each channel's value.
+ */
+export function WaveformChart({
+  preview,
+  compact = false,
+  className,
+}: {
+  preview: WaveformPreview
+  compact?: boolean
+  className?: string
+}) {
+  const t = useTranslations('faultCodes.waveforms.chart')
+  const locale = useLocale()
+  const clip = useId()
+  const height = compact ? 360 : 500
+  const [hover, setHover] = useState<number | null>(null)
+
+  const channels = useMemo(
+    () =>
+      preview.channels.flatMap((c, i) => {
+        const range = rangeOf(c)
+        if (!range) return []
+        return [
+          {
+            ...c,
+            color: channelColor(c.name, i),
+            range,
+            paths: bandPaths(c, range, height),
+          },
+        ]
+      }),
+    [preview, height]
+  )
+
+  const format = (v: number) =>
+    new Intl.NumberFormat(locale, { maximumSignificantDigits: 3 }).format(v)
+  const unit = preview.timeUnit ? ` ${preview.timeUnit}` : ''
+  const span = preview.end - preview.start
+  const slices = preview.channels[0]?.min.length ?? 1
+
+  return (
+    <figure className={cn('min-w-0', className)}>
+      <div
+        className={cn(
+          'relative overflow-hidden rounded-md border border-card-edge bg-muted/25',
+          compact ? 'aspect-[25/9]' : 'aspect-[2/1] sm:aspect-[5/2]'
+        )}
+      >
+        <svg
+          viewBox={`0 0 ${WIDTH} ${height}`}
+          preserveAspectRatio="none"
+          className="absolute inset-0 size-full"
+          role="img"
+          aria-label={t('label', {
+            channels: channels.map((c) => c.name).join(', '),
+            span: `${format(span)}${unit}`,
+          })}
+          onPointerMove={
+            compact
+              ? undefined
+              : (e) => {
+                  const box = e.currentTarget.getBoundingClientRect()
+                  const at = Math.floor(((e.clientX - box.left) / box.width) * slices)
+                  setHover(Math.max(0, Math.min(slices - 1, at)))
+                }
+          }
+          onPointerLeave={compact ? undefined : () => setHover(null)}
+        >
+          <clipPath id={clip}>
+            <rect width={WIDTH} height={height} />
+          </clipPath>
+          <g className="text-border" stroke="currentColor" strokeWidth={1}>
+            {Array.from({ length: DIVISIONS_X - 1 }, (_, i) => (
+              <line
+                key={`x${i}`}
+                x1={((i + 1) * WIDTH) / DIVISIONS_X}
+                x2={((i + 1) * WIDTH) / DIVISIONS_X}
+                y1={0}
+                y2={height}
+                vectorEffect="non-scaling-stroke"
+              />
+            ))}
+            {Array.from({ length: DIVISIONS_Y - 1 }, (_, i) => (
+              <line
+                key={`y${i}`}
+                x1={0}
+                x2={WIDTH}
+                y1={((i + 1) * height) / DIVISIONS_Y}
+                y2={((i + 1) * height) / DIVISIONS_Y}
+                vectorEffect="non-scaling-stroke"
+                strokeDasharray={i === DIVISIONS_Y / 2 - 1 ? undefined : '2 4'}
+              />
+            ))}
+          </g>
+          <g clipPath={`url(#${clip})`}>
+            {channels.map((c) =>
+              c.paths.map((d, i) => (
+                <path
+                  key={`${c.name}-${i}`}
+                  d={d}
+                  fill={c.color}
+                  fillOpacity={0.9}
+                  stroke={c.color}
+                  strokeWidth={compact ? 1.25 : 1.5}
+                  strokeLinejoin="round"
+                  vectorEffect="non-scaling-stroke"
+                />
+              ))
+            )}
+          </g>
+          {hover !== null && (
+            <line
+              x1={((hover + 0.5) * WIDTH) / slices}
+              x2={((hover + 0.5) * WIDTH) / slices}
+              y1={0}
+              y2={height}
+              className="text-foreground"
+              stroke="currentColor"
+              strokeOpacity={0.5}
+              vectorEffect="non-scaling-stroke"
+            />
+          )}
+        </svg>
+        {!compact && (
+          <span className="pointer-events-none absolute right-2 bottom-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
+            {t('perDivision', { value: `${format(span / DIVISIONS_X)}${unit}` })}
+          </span>
+        )}
+      </div>
+
+      {!compact && (
+        <figcaption className="mt-2 space-y-1.5">
+          <div className="flex justify-between text-[11px] tabular-nums text-muted-foreground">
+            <span>
+              {format(preview.start)}
+              {unit}
+            </span>
+            {hover !== null && (
+              <span className="text-foreground">
+                {format(preview.start + ((hover + 0.5) / slices) * span)}
+                {unit}
+              </span>
+            )}
+            <span>
+              {format(preview.end)}
+              {unit}
+            </span>
+          </div>
+          <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
+            {channels.map((c) => {
+              const lo = hover !== null ? c.min[hover] : null
+              const hi = hover !== null ? c.max[hover] : null
+              const u = c.unit ? ` ${c.unit}` : ''
+              return (
+                <li key={c.name} className="inline-flex items-center gap-1.5">
+                  <span
+                    className="size-2.5 shrink-0 rounded-full"
+                    style={{ backgroundColor: c.color }}
+                    aria-hidden
+                  />
+                  <span className="font-medium">{c.name}</span>
+                  <span className="tabular-nums text-muted-foreground">
+                    {hover !== null
+                      ? lo === null || hi === null
+                        ? t('noSample')
+                        : lo === hi || Math.abs(hi - lo) < Math.abs(hi) * 0.01
+                          ? `${format(hi)}${u}`
+                          : t('between', { lo: `${format(lo)}${u}`, hi: `${format(hi)}${u}` })
+                      : t('between', {
+                          lo: `${format(Math.min(...(c.min.filter((v) => v !== null) as number[])))}${u}`,
+                          hi: `${format(Math.max(...(c.max.filter((v) => v !== null) as number[])))}${u}`,
+                        })}
+                  </span>
+                </li>
+              )
+            })}
+          </ul>
+        </figcaption>
+      )}
+    </figure>
+  )
+}

+ 66 - 0
src/features/fault-codes/Components/WaveformParts.tsx

@@ -0,0 +1,66 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { CircleCheck, TriangleAlert } from 'lucide-react'
+import { useServiceType } from '@/components/service-type-context'
+import { cn } from '@/lib/utils'
+import type {
+  FaultCodeOwnWaveform,
+  FaultCodeWaveform,
+  WaveformCondition,
+  WaveformFileRole,
+} from '@/features/integrations/Lib/types'
+
+/** Where the app serves a shared waveform's file from the code service. */
+export function waveformFileUrl(
+  id: string,
+  role: WaveformFileRole,
+  size: 'full' | 'thumb' = 'full'
+): string {
+  const base = `/api/protected/fault-codes/waveforms/${encodeURIComponent(id)}/files/${role}`
+  return size === 'thumb' ? `${base}?size=thumb` : base
+}
+
+/** A waveform on the page: a published one, or this workshop's own with its review. */
+export type ListedWaveform = FaultCodeWaveform & {
+  status?: FaultCodeOwnWaveform['status']
+  reason?: string | null
+  own: boolean
+}
+
+/** Known good and faulty, drawn the same way everywhere a waveform is named. */
+export function KindBadge({
+  kind,
+  className,
+}: {
+  kind: FaultCodeWaveform['kind']
+  className?: string
+}) {
+  const t = useTranslations('faultCodes.waveforms.kind')
+  const Icon = kind === 'good' ? CircleCheck : TriangleAlert
+  return (
+    <span
+      className={cn(
+        'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium',
+        kind === 'good'
+          ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
+          : 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
+        className
+      )}
+    >
+      <Icon className="size-3" aria-hidden />
+      {t(kind)}
+    </span>
+  )
+}
+
+/** How the engine ran, in words; a boat is under way rather than driving. */
+export function useConditionLabel() {
+  const t = useTranslations('faultCodes.waveforms.condition')
+  const isMarine = useServiceType() === 'marine'
+  return (c: WaveformCondition) => (c === 'driving' && isMarine ? t('drivingMarine') : t(c))
+}
+
+export function hasImage(w: FaultCodeWaveform): boolean {
+  return w.files.some((f) => f.role === 'image')
+}

+ 359 - 0
src/features/fault-codes/Components/WaveformViewer.tsx

@@ -0,0 +1,359 @@
+'use client'
+
+import { useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import {
+  Activity,
+  Download,
+  FileSpreadsheet,
+  ImageIcon,
+  Loader2,
+  Settings2,
+  Trash2,
+  Waves,
+  X,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useConfirm } from '@/components/confirm-dialog'
+import { useServiceType } from '@/components/service-type-context'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogBody,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { useFormatDate } from '@/lib/use-format-date'
+import { track } from '@/lib/analytics'
+import { cn } from '@/lib/utils'
+import { formatBytes } from '@/features/integrations/Lib/format-bytes'
+import type { WaveformFileRole } from '@/features/integrations/Lib/types'
+import { withdrawFaultCodeWaveform } from '../Actions/withdrawFaultCodeWaveform'
+import { fixVehicleLine } from './FixPhotos'
+import { WaveformChart } from './WaveformChart'
+import {
+  hasImage,
+  KindBadge,
+  type ListedWaveform,
+  useConditionLabel,
+  waveformFileUrl,
+} from './WaveformParts'
+
+const FILE_ICONS: Record<Exclude<WaveformFileRole, 'image'>, typeof Waves> = {
+  psdata: Waves,
+  pssettings: Settings2,
+  csv: FileSpreadsheet,
+}
+
+/** "Norwegian" for nb, in the reader's language; null when it is the reader's own. */
+function languageName(lang: string, locale: string): string | null {
+  if (lang.toLowerCase() === locale.toLowerCase()) return null
+  try {
+    return new Intl.DisplayNames([locale], { type: 'language' }).of(lang) ?? null
+  } catch {
+    return null
+  }
+}
+
+/**
+ * One waveform, full size: the trace with each channel's value under the pointer, the
+ * picture of the screen, what the technician noted, and the files to open in PicoScope.
+ * "Compare with" draws another waveform for the code under it on the same width, the way a
+ * technician holds a known good one against what the car shows.
+ */
+export function WaveformViewer({
+  waveform: w,
+  others,
+  code,
+  onOpenChange,
+  onWithdrawn,
+}: {
+  waveform: ListedWaveform
+  /** The code's other waveforms with something to draw, to compare against. */
+  others: ListedWaveform[]
+  code: string
+  onOpenChange: (open: boolean) => void
+  onWithdrawn: () => void
+}) {
+  const t = useTranslations('faultCodes.waveforms')
+  const locale = useLocale()
+  const isMarine = useServiceType() === 'marine'
+  const confirm = useConfirm()
+  const { formatDate } = useFormatDate()
+  const conditionLabel = useConditionLabel()
+  // A faulty one opens next to a known good one when there is one.
+  const [compareId, setCompareId] = useState<string>(
+    () =>
+      (w.kind === 'faulty' ? others.find((o) => o.kind === 'good' && o.preview)?.id : null) ??
+      'none'
+  )
+  const [withdrawing, setWithdrawing] = useState(false)
+  const compare = others.find((o) => o.id === compareId) ?? null
+
+  const withdraw = async () => {
+    const ok = await confirm({
+      title: t('withdrawTitle'),
+      description: t('withdrawConfirm'),
+      confirmLabel: t('withdraw'),
+      destructive: true,
+    })
+    if (!ok) return
+    setWithdrawing(true)
+    const result = await withdrawFaultCodeWaveform({ code, id: w.id })
+    setWithdrawing(false)
+    if (!result.success) {
+      toast.error(t('withdrawFailed'))
+      return
+    }
+    toast.success(t('withdrawn'))
+    onWithdrawn()
+  }
+
+  const vehicleLine = fixVehicleLine(w.vehicle)
+  const language = languageName(w.lang, locale)
+  const downloads = w.files.filter(
+    (f): f is typeof f & { role: Exclude<WaveformFileRole, 'image'> } => f.role !== 'image'
+  )
+  const order: Record<string, number> = { psdata: 0, csv: 1, pssettings: 2 }
+  downloads.sort((a, b) => order[a.role] - order[b.role])
+
+  return (
+    <Dialog open onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-5xl">
+        <DialogHeader>
+          <div className="flex flex-wrap items-center gap-2">
+            <KindBadge kind={w.kind} />
+            {w.own && w.status && w.status !== 'published' && (
+              <span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
+                {t(`status.${w.status}`)}
+              </span>
+            )}
+          </div>
+          <DialogTitle>{w.title}</DialogTitle>
+          <DialogDescription>
+            {[
+              w.condition ? conditionLabel(w.condition) : null,
+              vehicleLine ?? (isMarine ? t('unknownVehicleMarine') : t('unknownVehicle')),
+              t('sharedOn', { date: formatDate(w.createdAt) }),
+            ]
+              .filter(Boolean)
+              .join(' · ')}
+          </DialogDescription>
+        </DialogHeader>
+
+        <DialogBody className="space-y-6">
+          {w.own && w.status === 'rejected' && w.reason && (
+            <p className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
+              {t('reason', { reason: w.reason })}
+            </p>
+          )}
+
+          <Trace waveform={w} />
+
+          {others.length > 0 && (
+            <section className="space-y-3" aria-label={t('compare.label')}>
+              <div className="flex flex-wrap items-center gap-2">
+                <span className="text-sm font-medium">{t('compare.label')}</span>
+                <Select
+                  value={compareId}
+                  onValueChange={(next) => {
+                    if (next !== 'none') {
+                      const other = others.find((o) => o.id === next)
+                      track('fault_code:waveform_compare', {
+                        code,
+                        kinds: `${w.kind}+${other?.kind ?? 'unknown'}`,
+                      })
+                    }
+                    setCompareId(next)
+                  }}
+                >
+                  <SelectTrigger className="h-8 w-auto min-w-56 max-w-full text-sm">
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="none">{t('compare.none')}</SelectItem>
+                    {others.map((o) => (
+                      <SelectItem key={o.id} value={o.id}>
+                        {t(`kind.${o.kind}`)} · {o.title}
+                        {fixVehicleLine(o.vehicle) ? ` · ${fixVehicleLine(o.vehicle)}` : ''}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                {compare && (
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="xs"
+                    className="text-muted-foreground"
+                    onClick={() => setCompareId('none')}
+                  >
+                    <X />
+                    {t('compare.close')}
+                  </Button>
+                )}
+              </div>
+              {compare && (
+                <div className="space-y-2 rounded-lg border border-card-edge bg-muted/15 p-3">
+                  <div className="flex flex-wrap items-center gap-2 text-sm">
+                    <KindBadge kind={compare.kind} />
+                    <span className="font-medium">{compare.title}</span>
+                    <span className="text-xs text-muted-foreground">
+                      {[
+                        compare.condition ? conditionLabel(compare.condition) : null,
+                        fixVehicleLine(compare.vehicle),
+                      ]
+                        .filter(Boolean)
+                        .join(' · ')}
+                    </span>
+                  </div>
+                  <Trace key={compare.id} waveform={compare} />
+                </div>
+              )}
+            </section>
+          )}
+
+          {w.note && (
+            <section className="space-y-1.5">
+              <h3 className="text-sm font-medium">{t('note')}</h3>
+              <p className="whitespace-pre-line text-sm text-muted-foreground">{w.note}</p>
+              {language && (
+                <p className="text-xs text-muted-foreground">{t('writtenIn', { language })}</p>
+              )}
+            </section>
+          )}
+
+          {downloads.length > 0 && (
+            <section className="space-y-2">
+              <h3 className="text-sm font-medium">{t('files.title')}</h3>
+              <ul className="grid gap-2 sm:grid-cols-3">
+                {downloads.map((f) => {
+                  const Icon = FILE_ICONS[f.role]
+                  return (
+                    <li key={f.role}>
+                      <a
+                        href={waveformFileUrl(w.id, f.role)}
+                        download={f.name}
+                        onClick={() =>
+                          track('fault_code:waveform_download', {
+                            code,
+                            role: f.role,
+                            kind: w.kind,
+                          })
+                        }
+                        className="flex items-center gap-3 rounded-lg border border-card-edge px-3 py-2.5 transition-colors hover:border-primary/40 hover:bg-primary/5"
+                      >
+                        <Icon className="size-4 shrink-0 text-primary" aria-hidden />
+                        <span className="min-w-0 flex-1">
+                          <span className="block text-sm font-medium">{t(`files.${f.role}`)}</span>
+                          <span className="block truncate text-xs text-muted-foreground">
+                            {f.name} · {formatBytes(f.size)}
+                          </span>
+                        </span>
+                        <Download className="size-4 shrink-0 text-muted-foreground" aria-hidden />
+                      </a>
+                    </li>
+                  )
+                })}
+              </ul>
+              {downloads.some((f) => f.role === 'psdata') && (
+                <p className="text-xs text-muted-foreground">{t('files.openHint')}</p>
+              )}
+            </section>
+          )}
+        </DialogBody>
+
+        <DialogFooter className="sm:justify-between">
+          {w.own ? (
+            <Button
+              type="button"
+              variant="ghost"
+              className="text-muted-foreground"
+              disabled={withdrawing}
+              onClick={() => void withdraw()}
+            >
+              {withdrawing ? <Loader2 className="animate-spin" /> : <Trash2 />}
+              {t('withdraw')}
+            </Button>
+          ) : (
+            <span />
+          )}
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+            {t('close')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+/**
+ * The trace when there is a CSV export, the picture of the screen when there is one, and a
+ * switch between them when there are both. The picture opens full size in a new tab.
+ */
+function Trace({ waveform: w }: { waveform: ListedWaveform }) {
+  const t = useTranslations('faultCodes.waveforms')
+  const image = hasImage(w)
+  const [view, setView] = useState<'trace' | 'picture'>(w.preview ? 'trace' : 'picture')
+  if (!w.preview && !image) {
+    return <p className="text-sm text-muted-foreground">{t('nothingToDraw')}</p>
+  }
+  return (
+    <div className="space-y-2">
+      {w.preview && image && (
+        <div
+          className="inline-flex rounded-md border border-card-edge bg-muted/30 p-0.5"
+          role="group"
+          aria-label={t('view.label')}
+        >
+          {(['trace', 'picture'] as const).map((v) => (
+            <button
+              key={v}
+              type="button"
+              aria-pressed={view === v}
+              onClick={() => setView(v)}
+              className={cn(
+                'inline-flex items-center gap-1.5 rounded px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground',
+                view === v && 'bg-background text-foreground shadow-sm'
+              )}
+            >
+              {v === 'trace' ? (
+                <Activity className="size-3.5" aria-hidden />
+              ) : (
+                <ImageIcon className="size-3.5" aria-hidden />
+              )}
+              {t(`view.${v}`)}
+            </button>
+          ))}
+        </div>
+      )}
+      {view === 'trace' && w.preview ? (
+        <WaveformChart preview={w.preview} />
+      ) : (
+        <a
+          href={waveformFileUrl(w.id, 'image')}
+          target="_blank"
+          rel="noreferrer"
+          className="block overflow-hidden rounded-lg border border-card-edge bg-muted transition-opacity hover:opacity-95"
+          aria-label={t('openPicture')}
+        >
+          <img
+            src={waveformFileUrl(w.id, 'image')}
+            alt=""
+            className="max-h-[28rem] w-full object-contain"
+          />
+        </a>
+      )}
+    </div>
+  )
+}

+ 260 - 0
src/features/fault-codes/Components/WaveformsCard.tsx

@@ -0,0 +1,260 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Activity, ImageIcon, Upload } from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { useServiceType } from '@/components/service-type-context'
+import { Button } from '@/components/ui/button'
+import { track } from '@/lib/analytics'
+import { cn } from '@/lib/utils'
+import type { FaultCodeOwnWaveform, FaultCodeWaveform } from '@/features/integrations/Lib/types'
+import type { CodeVehicle, CodeWaveforms } from '../Lib/types'
+import { fixVehicleLine } from './FixPhotos'
+import { ShareWaveformDialog } from './ShareWaveformDialog'
+import { WaveformChart } from './WaveformChart'
+import {
+  hasImage,
+  KindBadge,
+  type ListedWaveform,
+  useConditionLabel,
+  waveformFileUrl,
+} from './WaveformParts'
+import { WaveformViewer } from './WaveformViewer'
+
+/**
+ * "Waveforms" on a code's page: scope recordings other workshops shared, known good and
+ * faulty, each drawn small from its CSV export or shown as its picture, a click away from the
+ * full view, its files and a side-by-side compare. This workshop's own show with how their
+ * review stands, and "Share a waveform" opens the dialog to send one.
+ */
+export function WaveformsCard({
+  code,
+  label = code,
+  meaning = null,
+  makes = null,
+  vehicle,
+  state,
+}: {
+  code: string
+  /** The code as people read it ("BMW 283700"); the code itself otherwise. */
+  label?: string
+  /** For a make's own code, the meaning on screen: only its waveforms, and the code's as a whole. */
+  meaning?: { id: string; title: string } | null
+  makes?: string[] | null
+  vehicle: CodeVehicle | null
+  state: CodeWaveforms
+}) {
+  const t = useTranslations('faultCodes.waveforms')
+  const isMarine = useServiceType() === 'marine'
+  const [own, setOwn] = useState<FaultCodeOwnWaveform[]>(state.own)
+  const [published, setPublished] = useState<FaultCodeWaveform[]>(state.published)
+  const [filter, setFilter] = useState<'all' | 'good' | 'faulty'>('all')
+  const [sharing, setSharing] = useState(false)
+  const [viewing, setViewing] = useState<string | null>(null)
+
+  // Ours first while they wait for review, then the published, known good before faulty.
+  const all = useMemo<ListedWaveform[]>(() => {
+    const ownIds = new Set(own.map((w) => w.id))
+    const waiting = own.filter((w) => w.status !== 'published').map((w) => ({ ...w, own: true }))
+    const live = [
+      ...published.map((w) => ({ ...w, own: ownIds.has(w.id) })),
+      ...own
+        .filter((w) => w.status === 'published' && !published.some((p) => p.id === w.id))
+        .map((w) => ({ ...w, own: true })),
+    ].sort((a, b) =>
+      a.kind === b.kind ? b.createdAt.localeCompare(a.createdAt) : a.kind === 'good' ? -1 : 1
+    )
+    return [...waiting, ...live].filter(
+      (w) => !meaning || w.meaning === null || w.meaning === meaning.id
+    )
+  }, [own, published, meaning])
+
+  const kinds = new Set(all.map((w) => w.kind))
+  // A kind no longer there (the last one withdrawn, another meaning chosen) shows them all.
+  const active = filter !== 'all' && kinds.has(filter) ? filter : 'all'
+  const shown = active === 'all' ? all : all.filter((w) => w.kind === active)
+  const open = all.find((w) => w.id === viewing) ?? null
+
+  const removed = (id: string) => {
+    setOwn((current) => current.filter((w) => w.id !== id))
+    setPublished((current) => current.filter((w) => w.id !== id))
+    setViewing(null)
+  }
+
+  return (
+    <AppCard
+      icon={Activity}
+      title={t('title')}
+      description={t('description')}
+      badge={all.length > 0 ? all.length : undefined}
+      action={
+        state.available && all.length > 0 ? (
+          <Button type="button" size="sm" variant="outline" onClick={() => setSharing(true)}>
+            <Upload />
+            {t('share')}
+          </Button>
+        ) : undefined
+      }
+      contentClassName="space-y-4"
+    >
+      {!state.available ? (
+        <p className="text-sm text-muted-foreground">{t('unavailable')}</p>
+      ) : all.length === 0 ? (
+        <div className="flex flex-col items-start gap-4 rounded-lg border border-dashed border-border bg-muted/20 p-5 sm:flex-row sm:items-center">
+          <span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
+            <Activity className="size-5" aria-hidden />
+          </span>
+          <div className="min-w-0 flex-1 space-y-1">
+            <p className="font-medium">{t('empty', { code: label })}</p>
+            <p className="text-sm text-muted-foreground">{t('emptyHint')}</p>
+          </div>
+          <Button type="button" onClick={() => setSharing(true)} className="shrink-0">
+            <Upload />
+            {t('share')}
+          </Button>
+        </div>
+      ) : (
+        <>
+          {kinds.size > 1 && (
+            <div
+              className="inline-flex rounded-md border border-card-edge bg-muted/30 p-0.5"
+              role="group"
+              aria-label={t('filter.label')}
+            >
+              {(['all', 'good', 'faulty'] as const).map((f) => (
+                <button
+                  key={f}
+                  type="button"
+                  aria-pressed={active === f}
+                  onClick={() => setFilter(f)}
+                  className={cn(
+                    'rounded px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground',
+                    active === f && 'bg-background text-foreground shadow-sm'
+                  )}
+                >
+                  {t(`filter.${f}`)}
+                </button>
+              ))}
+            </div>
+          )}
+          <ul className="grid gap-3 sm:grid-cols-2">
+            {shown.map((w) => (
+              <li key={w.id}>
+                <WaveformTile
+                  waveform={w}
+                  unknownVehicle={isMarine ? t('unknownVehicleMarine') : t('unknownVehicle')}
+                  onOpen={() => {
+                    track('fault_code:waveform_open', {
+                      code,
+                      kind: w.kind,
+                      trace: Boolean(w.preview),
+                      own: w.own,
+                    })
+                    setViewing(w.id)
+                  }}
+                />
+              </li>
+            ))}
+          </ul>
+        </>
+      )}
+
+      {open && (
+        <WaveformViewer
+          waveform={open}
+          others={all.filter((w) => w.id !== open.id && (w.preview || hasImage(w)))}
+          code={code}
+          onOpenChange={(next) => !next && setViewing(null)}
+          onWithdrawn={() => removed(open.id)}
+        />
+      )}
+
+      {state.available && (
+        <ShareWaveformDialog
+          open={sharing}
+          onOpenChange={setSharing}
+          code={code}
+          label={label}
+          meaning={meaning}
+          makes={makes}
+          vehicle={vehicle}
+          onShared={(w) => setOwn((current) => [w, ...current])}
+        />
+      )}
+    </AppCard>
+  )
+}
+
+/** One waveform in the list: its picture of the trace, what it shows and on what. */
+function WaveformTile({
+  waveform: w,
+  unknownVehicle,
+  onOpen,
+}: {
+  waveform: ListedWaveform
+  unknownVehicle: string
+  onOpen: () => void
+}) {
+  const t = useTranslations('faultCodes.waveforms')
+  const conditionLabel = useConditionLabel()
+  const vehicle = fixVehicleLine(w.vehicle)
+  const files = w.files.filter((f) => f.role === 'psdata' || f.role === 'csv').length
+  return (
+    <button
+      type="button"
+      onClick={onOpen}
+      className="group flex w-full flex-col overflow-hidden rounded-lg border border-card-edge bg-card text-left transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+    >
+      <div className="relative">
+        {w.preview ? (
+          <WaveformChart
+            preview={w.preview}
+            compact
+            className="[&>div]:rounded-none [&>div]:border-0 [&>div]:border-b"
+          />
+        ) : hasImage(w) ? (
+          <img
+            src={waveformFileUrl(w.id, 'image', 'thumb')}
+            alt=""
+            loading="lazy"
+            className="aspect-[25/9] w-full border-b border-card-edge bg-muted object-cover"
+          />
+        ) : (
+          <div className="flex aspect-[25/9] items-center justify-center border-b border-card-edge bg-muted/25 text-muted-foreground">
+            <ImageIcon className="size-5" aria-hidden />
+          </div>
+        )}
+        <div className="absolute top-2 left-2 flex flex-wrap gap-1">
+          <span className="rounded-full bg-background shadow-sm">
+            <KindBadge kind={w.kind} />
+          </span>
+          {w.own && w.status && w.status !== 'published' && (
+            <span
+              className={cn(
+                'inline-flex items-center rounded-full border bg-background/90 px-2 py-0.5 text-[11px] font-medium',
+                w.status === 'pending' && 'text-muted-foreground',
+                w.status === 'rejected' && 'border-destructive/40 text-destructive'
+              )}
+            >
+              {t(`status.${w.status}`)}
+            </span>
+          )}
+        </div>
+      </div>
+      <div className="min-w-0 space-y-1 px-3.5 py-3">
+        <p className="truncate text-sm font-medium group-hover:text-primary">{w.title}</p>
+        <p className="truncate text-xs text-muted-foreground">
+          {[w.condition ? conditionLabel(w.condition) : null, vehicle ?? unknownVehicle]
+            .filter(Boolean)
+            .join(' · ')}
+        </p>
+        {files > 0 && (
+          <p className="text-[11px] text-muted-foreground">
+            {w.files.some((f) => f.role === 'psdata') ? t('hasRecording') : t('hasCsv')}
+          </p>
+        )}
+      </div>
+    </button>
+  )
+}

+ 11 - 2
src/features/fault-codes/Components/WorkshopFixesCard.tsx

@@ -26,13 +26,22 @@ function languageName(lang: string, locale: string): string | null {
  */
  */
 export function WorkshopFixesCard({
 export function WorkshopFixesCard({
   code,
   code,
-  fixes,
+  label = code,
+  meaning = null,
+  fixes: allFixes,
   available,
   available,
 }: {
 }: {
   code: string
   code: string
+  /** The code as people read it ("BMW 283700"); the code itself otherwise. */
+  label?: string
+  /** For a make's own code, the meaning on screen: only its fixes, and those for the code as a whole. */
+  meaning?: string | null
   fixes: SharedFix[]
   fixes: SharedFix[]
   available: boolean
   available: boolean
 }) {
 }) {
+  const fixes = meaning
+    ? allFixes.filter((f) => f.meaning === null || f.meaning === meaning)
+    : allFixes
   const t = useTranslations('faultCodes.fixes')
   const t = useTranslations('faultCodes.fixes')
   const locale = useLocale()
   const locale = useLocale()
   const isMarine = useServiceType() === 'marine'
   const isMarine = useServiceType() === 'marine'
@@ -48,7 +57,7 @@ export function WorkshopFixesCard({
       {!available ? (
       {!available ? (
         <p className="text-sm text-muted-foreground">{t('unavailable')}</p>
         <p className="text-sm text-muted-foreground">{t('unavailable')}</p>
       ) : fixes.length === 0 ? (
       ) : fixes.length === 0 ? (
-        <p className="text-sm text-muted-foreground">{t('empty', { code })}</p>
+        <p className="text-sm text-muted-foreground">{t('empty', { code: label })}</p>
       ) : (
       ) : (
         <ul className="divide-y divide-card-edge/60">
         <ul className="divide-y divide-card-edge/60">
           {fixes.map((fix) => {
           {fixes.map((fix) => {

+ 261 - 0
src/features/fault-codes/Lib/codeImportFile.server.ts

@@ -0,0 +1,261 @@
+import 'server-only'
+import { ImportParseError, decodeText, parseImportFile } from '@/features/import/Lib/parse'
+import type { CodeImportRow } from '@/features/integrations/Lib/types'
+
+/**
+ * A workshop's file of fault codes, read into rows the code service checks: a spreadsheet
+ * (Excel or CSV, one row per code) or JSON, as the example files show. Columns are known by
+ * their header whatever its case or spacing, and a few other words people use for them
+ * ("DTC", "How to fix it"). Causes in a cell are one per line or split by semicolons, each
+ * with its likelihood in brackets if the file says it: "Battery disconnected (high)".
+ */
+
+export const MAX_CODE_IMPORT_BYTES = 5 * 1024 * 1024
+export const MAX_CODE_IMPORT_ROWS = 2000
+
+/** Why a file could not be read as codes at all. */
+export type CodeFileProblem =
+  | 'unreadable'
+  | 'empty'
+  | 'no_code_column'
+  | 'too_many'
+  | 'format'
+  | 'legacy_xls'
+
+export class CodeFileError extends Error {
+  constructor(readonly problem: CodeFileProblem) {
+    super(problem)
+  }
+}
+
+export interface CodeFile {
+  rows: CodeImportRow[]
+  /** What a JSON file says of itself, to fill the dialog's fields with. */
+  make?: string
+  language?: string
+  source?: string
+}
+
+type Field = keyof Omit<CodeImportRow, 'causes'> | 'causes'
+
+const HEADERS: Record<Field, string[]> = {
+  code: ['code', 'dtc', 'fault code', 'trouble code', 'error code', 'dtc code'],
+  title: ['title', 'name', 'short description', 'short text'],
+  description: ['description', 'what it means', 'meaning', 'details'],
+  commonIssues: ['common issues', 'common problems', 'known issues'],
+  resolution: ['how to fix', 'how to fix it', 'resolution', 'fix', 'repair', 'solution'],
+  advisory: ['good to know', 'advice', 'advisory', 'notes', 'note', 'tips'],
+  causes: ['causes', 'possible causes', 'likely causes', 'cause'],
+}
+
+const JSON_KEYS: Record<Field, string[]> = {
+  code: ['code', 'dtc'],
+  title: ['title', 'name'],
+  description: ['description', 'what_it_means', 'meaning'],
+  commonIssues: ['common_issues', 'commonIssues'],
+  resolution: ['how_to_fix', 'howToFix', 'resolution', 'fix'],
+  advisory: ['good_to_know', 'goodToKnow', 'advice', 'advisory', 'notes'],
+  causes: ['causes', 'possible_causes'],
+}
+
+function headerKey(header: string): string {
+  return header
+    .toLowerCase()
+    .replace(/[_\-:]+/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+}
+
+/** "Battery disconnected (high)" as its label and likelihood. */
+export function readCause(text: string): { label: string; likelihood?: string } | null {
+  const trimmed = text.trim().replace(/^[-*•]\s*/, '')
+  if (!trimmed) return null
+  const m = /^(.*?)\s*[([]\s*(low|medium|high)\s*[)\]]$/i.exec(trimmed)
+  if (m?.[1]) return { label: m[1].trim(), likelihood: m[2].toLowerCase() }
+  return { label: trimmed }
+}
+
+/** A cell of causes, one per line or split by semicolons. */
+export function readCauses(cell: string): { label: string; likelihood?: string }[] {
+  return cell
+    .split(/\r?\n|;/)
+    .map(readCause)
+    .filter((c): c is { label: string; likelihood?: string } => c !== null)
+}
+
+function text(value: unknown): string | undefined {
+  if (typeof value !== 'string' && typeof value !== 'number') return undefined
+  const s = String(value).trim()
+  return s || undefined
+}
+
+function fromSheet(columns: string[], rows: string[][]): CodeImportRow[] {
+  const at = new Map<Field, number>()
+  columns.forEach((header, i) => {
+    const key = headerKey(header)
+    for (const [field, names] of Object.entries(HEADERS) as [Field, string[]][]) {
+      if (!at.has(field) && names.includes(key)) at.set(field, i)
+    }
+  })
+  const codeAt = at.get('code')
+  if (codeAt === undefined) throw new CodeFileError('no_code_column')
+  const cell = (row: string[], field: Field) => {
+    const i = at.get(field)
+    return i === undefined ? undefined : text(row[i])
+  }
+  return rows
+    .filter((row) => row.some((c) => c.trim() !== ''))
+    .map((row) => ({
+      code: cell(row, 'code') ?? '',
+      title: cell(row, 'title'),
+      description: cell(row, 'description'),
+      commonIssues: cell(row, 'commonIssues'),
+      resolution: cell(row, 'resolution'),
+      advisory: cell(row, 'advisory'),
+      causes: readCauses(cell(row, 'causes') ?? ''),
+    }))
+}
+
+function jsonField(item: Record<string, unknown>, field: Field): unknown {
+  for (const key of JSON_KEYS[field]) if (key in item) return item[key]
+  return undefined
+}
+
+function fromJson(raw: string): CodeFile {
+  let parsed: unknown
+  try {
+    parsed = JSON.parse(raw.replace(/^/, ''))
+  } catch {
+    throw new CodeFileError('unreadable')
+  }
+  const top = Array.isArray(parsed) ? { codes: parsed } : (parsed as Record<string, unknown>)
+  const codes = top && typeof top === 'object' ? top.codes : undefined
+  if (!Array.isArray(codes)) throw new CodeFileError('format')
+  const rows = codes.map((entry): CodeImportRow => {
+    const item = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>
+    const causes = jsonField(item, 'causes')
+    return {
+      code: text(jsonField(item, 'code')) ?? '',
+      title: text(jsonField(item, 'title')),
+      description: text(jsonField(item, 'description')),
+      commonIssues: text(jsonField(item, 'commonIssues')),
+      resolution: text(jsonField(item, 'resolution')),
+      advisory: text(jsonField(item, 'advisory')),
+      causes: Array.isArray(causes)
+        ? causes.flatMap((c) => {
+            if (typeof c === 'string') return readCause(c) ?? []
+            if (c && typeof c === 'object' && 'label' in c) {
+              const label = text((c as { label: unknown }).label)
+              const likelihood = text((c as { likelihood?: unknown }).likelihood)?.toLowerCase()
+              return label ? [{ label, ...(likelihood ? { likelihood } : {}) }] : []
+            }
+            return []
+          })
+        : typeof causes === 'string'
+          ? readCauses(causes)
+          : [],
+    }
+  })
+  const meta = top as Record<string, unknown>
+  return {
+    rows,
+    make: text(meta.make),
+    language: text(meta.language ?? meta.lang),
+    source: text(meta.source),
+  }
+}
+
+/** The codes in a file, by its name and content; throws CodeFileError when it has none. */
+export async function readCodeFile(buffer: Buffer, fileName: string): Promise<CodeFile> {
+  if (buffer.length > MAX_CODE_IMPORT_BYTES) throw new CodeFileError('too_many')
+  let file: CodeFile
+  if (fileName.toLowerCase().endsWith('.json')) {
+    file = fromJson(decodeText(buffer).text)
+  } else {
+    try {
+      const sheet = await parseImportFile(buffer, fileName)
+      file = { rows: fromSheet(sheet.columns, sheet.rows) }
+    } catch (error) {
+      if (error instanceof CodeFileError) throw error
+      if (error instanceof ImportParseError) {
+        if (error.code === 'empty') throw new CodeFileError('empty')
+        if (error.code === 'legacy_xls') throw new CodeFileError('legacy_xls')
+        if (error.code === 'too_many_rows') throw new CodeFileError('too_many')
+        if (error.code === 'unsupported_format') throw new CodeFileError('format')
+      }
+      throw new CodeFileError('unreadable')
+    }
+  }
+  if (file.rows.length === 0) throw new CodeFileError('empty')
+  if (file.rows.length > MAX_CODE_IMPORT_ROWS) throw new CodeFileError('too_many')
+  return file
+}
+
+/** The example rows, the same in the spreadsheet and the JSON the dialog offers. */
+export const EXAMPLE_ROWS: CodeImportRow[] = [
+  {
+    code: 'P1000',
+    title: 'OBD readiness tests not complete',
+    description:
+      'The engine computer has not finished its self tests since the fault memory was cleared or the battery was disconnected.',
+    commonIssues:
+      'Shows after a battery change or a cleared fault memory. It is not a fault in itself.',
+    resolution:
+      'Drive a full drive cycle: a cold start, idle, town driving and a steady motorway run. Read the codes again.',
+    advisory: 'An inspection station may refuse the car until the readiness tests are complete.',
+    causes: [
+      { label: 'Battery disconnected or fault memory cleared', likelihood: 'high' },
+      { label: 'A drive cycle not completed', likelihood: 'medium' },
+    ],
+  },
+  {
+    code: 'B1342',
+    title: 'Engine computer reports an internal fault',
+    causes: [{ label: 'Water in the engine computer', likelihood: 'low' }],
+  },
+]
+
+/** The spreadsheet's headers, in the order of its columns. */
+export const EXAMPLE_HEADERS = [
+  'Code',
+  'Title',
+  'Description',
+  'Common issues',
+  'How to fix',
+  'Good to know',
+  'Causes',
+] as const
+
+export function exampleSheetRows(): string[][] {
+  return EXAMPLE_ROWS.map((r) => [
+    r.code,
+    r.title ?? '',
+    r.description ?? '',
+    r.commonIssues ?? '',
+    r.resolution ?? '',
+    r.advisory ?? '',
+    r.causes.map((c) => (c.likelihood ? `${c.label} (${c.likelihood})` : c.label)).join('\n'),
+  ])
+}
+
+/** The example as JSON, every field named as people read it. */
+export function exampleJson(): string {
+  return `${JSON.stringify(
+    {
+      make: 'Ford',
+      language: 'en',
+      source: 'Where the codes come from: a link, or the name of a book or manual',
+      codes: EXAMPLE_ROWS.map((r) => ({
+        code: r.code,
+        title: r.title,
+        ...(r.description ? { description: r.description } : {}),
+        ...(r.commonIssues ? { common_issues: r.commonIssues } : {}),
+        ...(r.resolution ? { how_to_fix: r.resolution } : {}),
+        ...(r.advisory ? { good_to_know: r.advisory } : {}),
+        causes: r.causes,
+      })),
+    },
+    null,
+    2
+  )}\n`
+}

+ 124 - 0
src/features/fault-codes/Lib/codeImportRequest.server.ts

@@ -0,0 +1,124 @@
+import 'server-only'
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { locales } from '@/i18n/config'
+import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { getCachedMembership } from '@/lib/cached-session'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import {
+  type CodeFile,
+  CodeFileError,
+  type CodeFileProblem,
+  MAX_CODE_IMPORT_BYTES,
+  readCodeFile,
+} from './codeImportFile.server'
+import { isMakeKey } from './makes'
+
+/** Why an import did not go through, as a code the dialog turns into words. */
+export type CodeImportError =
+  | 'unauthorized'
+  | 'forbidden'
+  | 'demo'
+  | 'restoring'
+  | 'invalid'
+  | 'file'
+  | 'rights'
+  | 'paused'
+  | 'too_large'
+  | 'nothing'
+  | 'unavailable'
+
+export function fail(status: number, error: CodeImportError, problem?: CodeFileProblem) {
+  return NextResponse.json({ success: false, error, problem }, { status })
+}
+
+const fieldsSchema = z.object({
+  make: z.string().refine(isMakeKey),
+  lang: z.enum(locales),
+  source: z
+    .string()
+    .trim()
+    .max(500)
+    .optional()
+    .transform((v) => v || undefined),
+})
+
+export type ReadImport =
+  | { ok: false; response: NextResponse }
+  | {
+      ok: true
+      organizationId: string
+      form: FormData
+      fields: z.infer<typeof fieldsSchema>
+      file: CodeFile
+      fileName: string
+    }
+
+/**
+ * An import request read and checked: who sends it, its make and language, and the codes in
+ * its file. Shared by the check and the send, which read the same form.
+ */
+export async function readImportRequest(request: NextRequest): Promise<ReadImport> {
+  const ctx = await getAuthContext()
+  if (!ctx) return { ok: false, response: fail(401, 'unauthorized') }
+  const membership = ctx.isAdmin ? null : await getCachedMembership(ctx.userId)
+  const allowed =
+    ctx.isAdmin ||
+    hasPermission(membership?.customRole?.permissions ?? [], {
+      action: PermissionAction.UPDATE,
+      subject: PermissionSubject.VEHICLES,
+    })
+  if (!allowed) return { ok: false, response: fail(403, 'forbidden') }
+  if (isRestoring(ctx.organizationId)) {
+    return {
+      ok: false,
+      response: NextResponse.json(
+        { success: false, error: 'restoring', message: RESTORE_IN_PROGRESS_MESSAGE },
+        { status: 409 }
+      ),
+    }
+  }
+  try {
+    assertContentLength(request, MAX_CODE_IMPORT_BYTES + 64 * 1024)
+  } catch {
+    return { ok: false, response: fail(413, 'too_large') }
+  }
+  let form: FormData
+  try {
+    form = await request.formData()
+  } catch {
+    return { ok: false, response: fail(400, 'invalid') }
+  }
+  const text = (name: string) => {
+    const value = form.get(name)
+    return typeof value === 'string' ? value : undefined
+  }
+  const fields = fieldsSchema.safeParse({
+    make: text('make'),
+    lang: text('lang'),
+    source: text('source'),
+  })
+  const upload = form.get('file')
+  if (!fields.success || !(upload instanceof File) || upload.size === 0) {
+    return { ok: false, response: fail(400, 'invalid') }
+  }
+  if (upload.size > MAX_CODE_IMPORT_BYTES) {
+    return { ok: false, response: fail(413, 'file', 'too_many') }
+  }
+  try {
+    const file = await readCodeFile(Buffer.from(await upload.arrayBuffer()), upload.name)
+    return {
+      ok: true,
+      organizationId: ctx.organizationId,
+      form,
+      fields: fields.data,
+      file,
+      fileName: upload.name.slice(0, 200),
+    }
+  } catch (error) {
+    const problem = error instanceof CodeFileError ? error.problem : 'unreadable'
+    return { ok: false, response: fail(400, 'file', problem) }
+  }
+}

+ 157 - 5
src/features/fault-codes/Lib/codes.ts

@@ -8,6 +8,8 @@
  * P0420, U0100, B1A2F. Stored upper case, with nothing around it.
  * P0420, U0100, B1A2F. Stored upper case, with nothing around it.
  */
  */
 
 
+import { canonicalMakeKey, isMakeKey, makeName } from './makes'
+
 export const FAULT_CODE_PATTERN = /^[PCBU][0-3][0-9A-F]{3}$/
 export const FAULT_CODE_PATTERN = /^[PCBU][0-3][0-9A-F]{3}$/
 
 
 /** The statuses a scan tool reports a code in. */
 /** The statuses a scan tool reports a code in. */
@@ -27,6 +29,113 @@ export function isFaultCode(input: string | null | undefined): boolean {
   return normalizeFaultCode(input) !== null
   return normalizeFaultCode(input) !== null
 }
 }
 
 
+/**
+ * A manufacturer's own code as its dealer tools show it: hex, without the
+ * system letter of an OBD code (BMW's 2A82, CD87, 00A0B1). It means something
+ * only on that make's cars, so it is read as one only where the make is known.
+ */
+export const OEM_CODE_PATTERN = /^[0-9A-F]{3,8}$/
+
+/** Makes whose own codes come from one manufacturer's data: BMW's covers MINI and Rolls-Royce. */
+const OEM_SOURCES: Record<string, string> = {
+  bmw: 'bmw',
+  mini: 'bmw',
+  'rolls-royce': 'bmw',
+  'rolls royce': 'bmw',
+}
+
+/** Makes whose own codes are hex, read from their control units' data. */
+const HEX_MAKES = new Set(['bmw'])
+
+/** The makes whose cars carry one make's codes, as people write them: BMW's are on MINIs too. */
+export const OEM_SOURCE_MAKES: Record<string, string[]> = { bmw: ['BMW', 'MINI', 'Rolls-Royce'] }
+
+/**
+ * The makes a fix for this code can be for: BMW, MINI or Rolls-Royce for
+ * BMW-2A82; null for an OBD code, which any make may have.
+ */
+export function fixMakesFor(fixCode: string): string[] | null {
+  const split = splitFixCode(fixCode)
+  return split ? (OEM_SOURCE_MAKES[split.make] ?? [makeName(split.make)]) : null
+}
+
+/** BMW-2A82 as bmw and 2A82, LAND-ROVER-P1000 as land-rover and P1000; null for an OBD code. */
+export function splitFixCode(fixCode: string): { make: string; code: string } | null {
+  const at = fixCode.lastIndexOf('-')
+  if (at < 2) return null
+  const make = fixCode.slice(0, at).toLowerCase()
+  if (!isMakeKey(make)) return null
+  const code = normalizeMakeCode(make, fixCode.slice(at + 1))
+  return code ? { make, code } : null
+}
+
+/** The make whose codes a vehicle make carries: a MINI and a Rolls-Royce carry BMW's. */
+const CODE_SOURCES: Record<string, string> = { mini: 'bmw', 'rolls-royce': 'bmw' }
+
+/**
+ * Whether a vehicle of `vehicleMake` carries `codeMake`'s own codes, by the make each names
+ * rather than how it is written: an Opel, a "Vauxhall" and an "Opel / Vauxhall" all carry
+ * opel's, a MINI carries BMW's.
+ */
+export function vehicleCarriesMake(
+  codeMake: string,
+  vehicleMake: string | null | undefined
+): boolean {
+  const key = canonicalMakeKey(vehicleMake)
+  if (!key) return false
+  return key === codeMake || CODE_SOURCES[key] === codeMake
+}
+
+/** A make's own code as shared fixes keep it, with its make: BMW-2A82. */
+export function oemFixCode(make: string, code: string): string {
+  return `${make.toUpperCase()}-${code}`
+}
+
+/** A shared fix's code: an OBD code (P0420) or a make's own (BMW-2A82), normalised; else null. */
+export function normalizeFixCode(input: string | null | undefined): string | null {
+  if (!input) return null
+  const obd = normalizeFaultCode(input)
+  if (obd) return obd
+  const split = splitFixCode(input.trim())
+  return split ? oemFixCode(split.make, split.code) : null
+}
+
+/**
+ * A make's own code in one form, as the code service reads it: BMW's hex (0x2a82 as 2A82),
+ * or a make's letter codes (Ford's p1000 as P1000), a failure-type byte after it left off.
+ */
+export function normalizeMakeCode(make: string, input: string | null | undefined): string | null {
+  if (HEX_MAKES.has(make)) return normalizeOemCode(input)
+  return normalizeAnyMakeCode(input)
+}
+
+/** Any make's code, for a search box that does not know the make yet. */
+export function normalizeAnyMakeCode(input: string | null | undefined): string | null {
+  if (!input) return null
+  let code = input.replace(/\s+/g, '').toUpperCase()
+  if (/^0X[0-9A-F]+$/.test(code)) code = code.slice(2)
+  code = code.replace(/[-:.][0-9A-F]{2}$/, '')
+  return /^[A-Z0-9]{3,12}$/.test(code) ? code : null
+}
+
+/** The manufacturer data a make's own codes are looked up in ("bmw"), or null. */
+export function oemSourceFor(make: string | null | undefined): string | null {
+  if (!make) return null
+  return OEM_SOURCES[make.trim().toLowerCase()] ?? null
+}
+
+/** 2A82 from " 0x2a82 "; null for an OBD code or anything that is not hex. */
+export function normalizeOemCode(input: string | null | undefined): string | null {
+  if (!input) return null
+  const code = input.trim().toUpperCase().replace(/\s+/g, '').replace(/^0X/, '')
+  if (normalizeFaultCode(code)) return null
+  return OEM_CODE_PATTERN.test(code) ? code : null
+}
+
+export function isOemCode(input: string | null | undefined): boolean {
+  return normalizeOemCode(input) !== null
+}
+
 /**
 /**
  * Whether a code is in a range a manufacturer defines (P1xxx, P30xx to
  * Whether a code is in a range a manufacturer defines (P1xxx, P30xx to
  * P33xx, B1, B2, C1, C2, U1, U2) rather than one SAE J2012 gives the same
  * P33xx, B1, B2, C1, C2, U1, U2) rather than one SAE J2012 gives the same
@@ -72,21 +181,54 @@ export interface ExtractedCode {
  * part number) is not one.
  * part number) is not one.
  */
  */
 const CODE_IN_TEXT = /(?<![0-9A-Z])([PCBU][0-3][0-9A-F]{3})(?:[-:][0-9A-F]{2})?(?![0-9A-Z])/gi
 const CODE_IN_TEXT = /(?<![0-9A-Z])([PCBU][0-3][0-9A-F]{3})(?:[-:][0-9A-F]{2})?(?![0-9A-Z])/gi
+/** A manufacturer code in running text: four to six hex digits, a whole word, maybe 0x first. */
+const OEM_IN_TEXT = /(?<![0-9A-Z])(?:0x)?([0-9A-F]{4,6})(?![0-9A-Z])/gi
 
 
 /**
 /**
  * Every code in pasted or typed text, once each, in the order they appear:
  * Every code in pasted or typed text, once each, in the order they appear:
  * "p0420, P0171 stored" is P0420 and P0171 (stored). A status word counts
  * "p0420, P0171 stored" is P0420 and P0171 (stored). A status word counts
  * for the code it follows, up to the next code or the end of the line, so a
  * for the code it follows, up to the next code or the end of the line, so a
  * list a scan tool exports line by line keeps its statuses.
  * list a scan tool exports line by line keeps its statuses.
+ *
+ * With `oem` (the car's make has its own code data), manufacturer codes
+ * count too: "2A82, CD87". A number without a letter (4110) counts only when
+ * the text is nothing but codes, so a year or a mileage in a sentence is not
+ * taken for one.
  */
  */
-export function extractFaultCodes(text: string): ExtractedCode[] {
+export function extractFaultCodes(text: string, options?: { oem?: boolean }): ExtractedCode[] {
   const out: ExtractedCode[] = []
   const out: ExtractedCode[] = []
   const seen = new Map<string, ExtractedCode>()
   const seen = new Map<string, ExtractedCode>()
-  const matches = [...text.matchAll(CODE_IN_TEXT)]
+  const obd = [...text.matchAll(CODE_IN_TEXT)].map((m) => ({
+    code: m[1].toUpperCase(),
+    index: m.index ?? 0,
+    length: m[0].length,
+  }))
+  const oem: typeof obd = []
+  if (options?.oem) {
+    const onlyCodes = text
+      .split(/[\s,;|]+/)
+      .filter(Boolean)
+      .every(
+        (word) =>
+          isFaultCode(word) ||
+          OEM_CODE_PATTERN.test(word.toUpperCase().replace(/^0X/, '')) ||
+          STATUS_WORDS[word.toLowerCase()] !== undefined
+      )
+    for (const m of text.matchAll(OEM_IN_TEXT)) {
+      const code = m[1].toUpperCase()
+      if (isFaultCode(code)) continue
+      // In running text a code mixes digits and letters (2A82, CD87): a year is all digits and
+      // "dead" or "face" all letters. Written with 0x, or in a list of nothing but codes, it is one.
+      const explicit = m[0].toLowerCase().startsWith('0x')
+      if (!onlyCodes && !explicit && !(/[A-F]/.test(code) && /[0-9]/.test(code))) continue
+      oem.push({ code, index: m.index ?? 0, length: m[0].length })
+    }
+  }
+  const matches = [...obd, ...oem].sort((a, b) => a.index - b.index)
   matches.forEach((match, i) => {
   matches.forEach((match, i) => {
-    const code = match[1].toUpperCase()
-    const start = (match.index ?? 0) + match[0].length
-    const end = i + 1 < matches.length ? (matches[i + 1].index ?? text.length) : text.length
+    const code = match.code
+    const start = match.index + match.length
+    const end = i + 1 < matches.length ? matches[i + 1].index : text.length
     // The rest of this code's own stretch: to the next code, a line break or a list separator.
     // The rest of this code's own stretch: to the next code, a line break or a list separator.
     const tail = text.slice(start, end).split(/[\n;,|]/)[0]
     const tail = text.slice(start, end).split(/[\n;,|]/)[0]
     const status = statusIn(tail)
     const status = statusIn(tail)
@@ -119,6 +261,16 @@ export function looksLikeFaultCode(query: string): boolean {
   return /^[PCBU][0-3][0-9A-F]{0,3}$/.test(q) && q.length >= 2
   return /^[PCBU][0-3][0-9A-F]{0,3}$/.test(q) && q.length >= 2
 }
 }
 
 
+/**
+ * Whether a search could be a make's own code typed in full (BMW's 283700 or
+ * 2A82): four to eight hex digits that are not an OBD code. Whether the make
+ * has it is the service's to say.
+ */
+export function looksLikeOemCode(query: string): boolean {
+  const code = normalizeOemCode(query)
+  return code !== null && code.length >= 4
+}
+
 /** "P0420 Catalyst efficiency below threshold", as findings with a code print. */
 /** "P0420 Catalyst efficiency below threshold", as findings with a code print. */
 export function faultLine(code: string, meaning: string | null | undefined): string {
 export function faultLine(code: string, meaning: string | null | undefined): string {
   const text = meaning?.trim()
   const text = meaning?.trim()

+ 3 - 2
src/features/fault-codes/Lib/fixDrafts.server.ts

@@ -92,13 +92,14 @@ async function inTurn<T>(key: string, work: () => Promise<T>): Promise<T> {
 export async function addDraftPhoto(
 export async function addDraftPhoto(
   organizationId: string,
   organizationId: string,
   draftId: string,
   draftId: string,
-  jpeg: Uint8Array
+  jpeg: Uint8Array,
+  max: number = MAX_FIX_PHOTOS
 ): Promise<{ name: string; count: number } | null> {
 ): Promise<{ name: string; count: number } | null> {
   const dir = folderOf(organizationId)
   const dir = folderOf(organizationId)
   if (!dir || !isDraftId(draftId)) return null
   if (!dir || !isDraftId(draftId)) return null
   return inTurn(`${organizationId}/${draftId}`, async () => {
   return inTurn(`${organizationId}/${draftId}`, async () => {
     const existing = await listDraftPhotos(organizationId, draftId)
     const existing = await listDraftPhotos(organizationId, draftId)
-    if (existing.length >= MAX_FIX_PHOTOS) return null
+    if (existing.length >= max) return null
     await mkdir(dir, { recursive: true })
     await mkdir(dir, { recursive: true })
     const name = `${prefixOf(draftId)}${crypto.randomBytes(6).toString('hex')}.jpg`
     const name = `${prefixOf(draftId)}${crypto.randomBytes(6).toString('hex')}.jpg`
     await writeFile(path.join(dir, name), jpeg)
     await writeFile(path.join(dir, name), jpeg)

+ 8 - 3
src/features/fault-codes/Lib/lookups.ts

@@ -10,6 +10,8 @@ const MAX = 10
 /** A lookup as it is recorded: a code that was opened, or words that were searched. */
 /** A lookup as it is recorded: a code that was opened, or words that were searched. */
 export type NewLookup =
 export type NewLookup =
   | { kind: 'code'; code: string; title: string | null }
   | { kind: 'code'; code: string; title: string | null }
+  /** A make's own code (BMW's 2A82), which opens on its make's page. */
+  | { kind: 'oem'; make: string; code: string; title: string | null }
   | { kind: 'words'; query: string }
   | { kind: 'words'; query: string }
 
 
 export type RecentLookup = NewLookup & { at: number }
 export type RecentLookup = NewLookup & { at: number }
@@ -17,15 +19,18 @@ export type RecentLookup = NewLookup & { at: number }
 const sameAs = (a: RecentLookup, b: NewLookup) =>
 const sameAs = (a: RecentLookup, b: NewLookup) =>
   a.kind === 'code' && b.kind === 'code'
   a.kind === 'code' && b.kind === 'code'
     ? a.code === b.code
     ? a.code === b.code
-    : a.kind === 'words' && b.kind === 'words'
-      ? a.query.toLowerCase() === b.query.toLowerCase()
-      : false
+    : a.kind === 'oem' && b.kind === 'oem'
+      ? a.make === b.make && a.code === b.code
+      : a.kind === 'words' && b.kind === 'words'
+        ? a.query.toLowerCase() === b.query.toLowerCase()
+        : false
 
 
 function valid(x: unknown): x is RecentLookup {
 function valid(x: unknown): x is RecentLookup {
   if (!x || typeof x !== 'object') return false
   if (!x || typeof x !== 'object') return false
   const r = x as Record<string, unknown>
   const r = x as Record<string, unknown>
   if (typeof r.at !== 'number') return false
   if (typeof r.at !== 'number') return false
   if (r.kind === 'code') return typeof r.code === 'string'
   if (r.kind === 'code') return typeof r.code === 'string'
+  if (r.kind === 'oem') return typeof r.code === 'string' && typeof r.make === 'string'
   if (r.kind === 'words') return typeof r.query === 'string'
   if (r.kind === 'words') return typeof r.query === 'string'
   return false
   return false
 }
 }

+ 119 - 0
src/features/fault-codes/Lib/makes.ts

@@ -0,0 +1,119 @@
+/**
+ * Makes as the code service keys them (`ford`, `land-rover`) and as people write them, for
+ * the import's make picker and for naming a make's codes. A make the service has that is not
+ * listed here still works; its name is made from its key.
+ */
+
+export interface Make {
+  key: string
+  name: string
+  /** Outboards and marine engines, offered to a marine workshop. */
+  marine?: boolean
+  /** Other ways a vehicle's make is written, as keys: a Vauxhall is an Opel, a VW a Volkswagen. */
+  aliases?: string[]
+}
+
+export const MAKES: Make[] = [
+  { key: 'acura', name: 'Acura' },
+  { key: 'alfa-romeo', name: 'Alfa Romeo' },
+  { key: 'audi', name: 'Audi' },
+  { key: 'bmw', name: 'BMW' },
+  { key: 'byd', name: 'BYD' },
+  { key: 'cadillac', name: 'Cadillac' },
+  { key: 'chevrolet', name: 'Chevrolet', aliases: ['chevy'] },
+  { key: 'chrysler', name: 'Chrysler' },
+  { key: 'citroen', name: 'Citroën' },
+  { key: 'cupra', name: 'Cupra' },
+  { key: 'dacia', name: 'Dacia' },
+  { key: 'dodge', name: 'Dodge' },
+  { key: 'ds', name: 'DS' },
+  { key: 'fiat', name: 'Fiat' },
+  { key: 'ford', name: 'Ford' },
+  { key: 'gmc', name: 'GMC' },
+  { key: 'honda', name: 'Honda' },
+  { key: 'hyundai', name: 'Hyundai' },
+  { key: 'infiniti', name: 'Infiniti' },
+  { key: 'isuzu', name: 'Isuzu' },
+  { key: 'iveco', name: 'Iveco' },
+  { key: 'jaguar', name: 'Jaguar' },
+  { key: 'jeep', name: 'Jeep' },
+  { key: 'kia', name: 'Kia' },
+  { key: 'land-rover', name: 'Land Rover', aliases: ['landrover', 'range-rover'] },
+  { key: 'lexus', name: 'Lexus' },
+  { key: 'man', name: 'MAN' },
+  { key: 'mazda', name: 'Mazda' },
+  { key: 'mercedes-benz', name: 'Mercedes-Benz', aliases: ['mercedes', 'mb', 'mercedesbenz'] },
+  { key: 'mg', name: 'MG' },
+  { key: 'mitsubishi', name: 'Mitsubishi' },
+  { key: 'nissan', name: 'Nissan' },
+  { key: 'opel', name: 'Opel / Vauxhall', aliases: ['vauxhall', 'opel-vauxhall'] },
+  { key: 'peugeot', name: 'Peugeot' },
+  { key: 'polestar', name: 'Polestar' },
+  { key: 'porsche', name: 'Porsche' },
+  { key: 'ram', name: 'Ram' },
+  { key: 'renault', name: 'Renault' },
+  { key: 'scania', name: 'Scania' },
+  { key: 'seat', name: 'SEAT' },
+  { key: 'skoda', name: 'Škoda' },
+  { key: 'smart', name: 'smart' },
+  { key: 'subaru', name: 'Subaru' },
+  { key: 'suzuki', name: 'Suzuki' },
+  { key: 'tesla', name: 'Tesla' },
+  { key: 'toyota', name: 'Toyota' },
+  { key: 'volkswagen', name: 'Volkswagen', aliases: ['vw'] },
+  { key: 'volvo', name: 'Volvo' },
+  { key: 'evinrude', name: 'Evinrude', marine: true },
+  { key: 'honda-marine', name: 'Honda Marine', marine: true },
+  { key: 'mercruiser', name: 'MerCruiser', marine: true },
+  { key: 'mercury', name: 'Mercury', marine: true },
+  { key: 'suzuki-marine', name: 'Suzuki Marine', marine: true },
+  { key: 'tohatsu', name: 'Tohatsu', marine: true },
+  { key: 'volvo-penta', name: 'Volvo Penta', marine: true },
+  { key: 'yamaha', name: 'Yamaha', marine: true },
+  { key: 'yanmar', name: 'Yanmar', marine: true },
+]
+
+const BY_KEY = new Map(MAKES.map((m) => [m.key, m]))
+
+/** A make as the service keys it: lower case letters and digits, words joined by dashes. */
+export const MAKE_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
+
+export function isMakeKey(value: string | null | undefined): value is string {
+  return !!value && value.length <= 30 && MAKE_KEY_PATTERN.test(value)
+}
+
+/** "Land Rover" as `land-rover`, "Škoda" as `skoda`; null when nothing is left of it. */
+export function makeKey(name: string): string | null {
+  const key = name
+    .normalize('NFKD')
+    .replace(/[̀-ͯ]/g, '')
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .replace(/^[0-9-]+/, '')
+    .slice(0, 30)
+    .replace(/-+$/, '')
+  return isMakeKey(key) ? key : null
+}
+
+/**
+ * The make a vehicle's make names, as the service keys it: "Vauxhall" and "Opel / Vauxhall"
+ * are opel, "Škoda" is skoda, "VW" volkswagen. A make not listed keeps its own key.
+ */
+export function canonicalMakeKey(name: string | null | undefined): string | null {
+  if (!name) return null
+  const key = makeKey(name)
+  if (!key) return null
+  if (BY_KEY.has(key)) return key
+  return MAKES.find((m) => m.aliases?.includes(key))?.key ?? key
+}
+
+/** How people write a make: "BMW", "Land Rover"; a key not listed as its words. */
+export function makeName(key: string): string {
+  const known = BY_KEY.get(key)
+  if (known) return known.name
+  return key
+    .split('-')
+    .map((w) => (w.length <= 3 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)))
+    .join(' ')
+}

+ 68 - 0
src/features/fault-codes/Lib/oem.ts

@@ -0,0 +1,68 @@
+import type { FaultCodeDetailView, OemMeaningView } from './types'
+
+/** One meaning of a make's code in the shape every code page reads. */
+export function oemDetail(
+  code: string,
+  makeName: string,
+  meaning: OemMeaningView | null
+): FaultCodeDetailView {
+  return {
+    code,
+    state: meaning ? 'found' : 'unknown',
+    range: 'manufacturer',
+    title: meaning?.title ?? null,
+    source: meaning ? 'manufacturer' : null,
+    make: makeName,
+    lang: meaning?.titleLang ?? null,
+    machineTranslated: false,
+    verified: false,
+    version: null,
+    flags: null,
+    description: meaning?.description ?? null,
+    sections: meaning?.sections ?? [],
+    causes: meaning?.causes ?? [],
+    related: [],
+    pendingCheck: false,
+  }
+}
+
+/** "3 Series" with its chassis codes and the years they span. */
+export interface ModelLine {
+  model: string
+  chassis: string[]
+  from: number | null
+  /** Null while a chassis of it is still built ("from 2018"). */
+  to: number | null
+}
+
+/** A meaning's vehicles as models with their years; chassis our reference does not know, apart. */
+export function modelLines(vehicles: OemMeaningView['vehicles']): {
+  lines: ModelLine[]
+  other: string[]
+} {
+  const byModel = new Map<string, ModelLine & { open: boolean }>()
+  const other: string[] = []
+  for (const v of vehicles) {
+    if (!v.model) {
+      other.push(v.chassis)
+      continue
+    }
+    const line = byModel.get(v.model) ?? {
+      model: v.model,
+      chassis: [],
+      from: null,
+      to: null,
+      open: false,
+    }
+    line.chassis.push(v.chassis)
+    if (v.yearFrom !== null) line.from = Math.min(line.from ?? v.yearFrom, v.yearFrom)
+    if (v.yearTo !== null) line.to = Math.max(line.to ?? v.yearTo, v.yearTo)
+    if (v.yearFrom !== null && v.yearTo === null) line.open = true
+    byModel.set(v.model, line)
+  }
+  const lines = [...byModel.values()].map(({ open, ...line }) => ({
+    ...line,
+    to: open ? null : line.to,
+  }))
+  return { lines, other }
+}

+ 102 - 18
src/features/fault-codes/Lib/page.server.ts

@@ -4,11 +4,26 @@ import {
   faultCodesConnected,
   faultCodesConnected,
   fixesFor,
   fixesFor,
   jobsWithCode,
   jobsWithCode,
+  oemCodeFor,
+  oemMakesFor,
   reportStates,
   reportStates,
+  researchFor,
+  searchOemCodes,
+  waveformsFor,
   vehicleForCodes,
   vehicleForCodes,
 } from './service.server'
 } from './service.server'
 import { getManifest } from '@/integrations/registry'
 import { getManifest } from '@/integrations/registry'
-import type { CodeFixes, FaultCodePageData, ReportState } from './types'
+import { oemFixCode } from './codes'
+import { makeKey, makeName as nameOfMake } from './makes'
+import { oemDetail } from './oem'
+import type {
+  CodeFixes,
+  CodeVehicle,
+  CodeWaveforms,
+  FaultCodePageData,
+  ReportState,
+  ResearchState,
+} from './types'
 
 
 export type { FaultCodePageData }
 export type { FaultCodePageData }
 
 
@@ -27,16 +42,29 @@ export async function loadFaultCodePage(
 ): Promise<FaultCodePageData> {
 ): Promise<FaultCodePageData> {
   const vehicle = await vehicleForCodes(organizationId, vehicleId)
   const vehicle = await vehicleForCodes(organizationId, vehicleId)
   const connected = await faultCodesConnected(organizationId)
   const connected = await faultCodesConnected(organizationId)
-  const [detail, jobs, reports, fixes] = await Promise.all([
-    codeDetailFor(organizationId, code, vehicle, locale),
-    jobsWithCode(organizationId, code),
-    connected ? reportStates(organizationId, [code]) : Promise.resolve({}),
-    options.fixes && connected
-      ? fixesFor(organizationId, code)
-      : Promise.resolve<CodeFixes | null>(null),
-  ])
+  const [detail, jobs, reports, fixes, oemMakes, research, waveforms, makeCodes] =
+    await Promise.all([
+      codeDetailFor(organizationId, code, vehicle, locale),
+      jobsWithCode(organizationId, code),
+      connected ? reportStates(organizationId, [code]) : Promise.resolve({}),
+      options.fixes && connected
+        ? fixesFor(organizationId, code)
+        : Promise.resolve<CodeFixes | null>(null),
+      connected ? oemMakesFor(organizationId) : Promise.resolve([]),
+      options.fixes && connected
+        ? researchFor(organizationId, code)
+        : Promise.resolve<ResearchState | null>(null),
+      options.fixes && connected
+        ? waveformsFor(organizationId, code)
+        : Promise.resolve<CodeWaveforms | null>(null),
+      connected ? searchOemCodes(organizationId, code, locale) : Promise.resolve([]),
+    ])
+  // The car's own make first: on a Ford, Ford's P1000 is the one that counts.
+  const ownMake = vehicle?.make ? makeKey(vehicle.make) : null
+  makeCodes.sort((a, b) => Number(b.make === ownMake) - Number(a.make === ownMake))
   return {
   return {
     code,
     code,
+    makeCodes,
     connected,
     connected,
     connectable: getManifest('torqvoice-codes') !== null,
     connectable: getManifest('torqvoice-codes') !== null,
     available: detail.available,
     available: detail.available,
@@ -44,14 +72,70 @@ export async function loadFaultCodePage(
     jobs,
     jobs,
     fixes,
     fixes,
     report: (reports as Record<string, ReportState>)[code] ?? null,
     report: (reports as Record<string, ReportState>)[code] ?? null,
-    vehicle: vehicle
-      ? {
-          id: vehicle.id,
-          make: vehicle.make,
-          model: vehicle.model,
-          year: vehicle.year,
-          engine: vehicle.engine ?? null,
-        }
-      : null,
+    vehicle: codeVehicle(vehicle),
+    oemMakes,
+    research,
+    waveforms,
+  }
+}
+
+function codeVehicle(vehicle: Awaited<ReturnType<typeof vehicleForCodes>>): CodeVehicle | null {
+  return vehicle
+    ? {
+        id: vehicle.id,
+        make: vehicle.make,
+        model: vehicle.model,
+        year: vehicle.year,
+        engine: vehicle.engine ?? null,
+      }
+    : null
+}
+
+/**
+ * The page of a make's own code (BMW's 2A82) as a code page like any other:
+ * the meaning in the same parts, the jobs here, shared fixes under the
+ * code with its make (BMW-2A82), and every meaning the code has with where
+ * the make uses it. `detail` is the first meaning; the page switches.
+ */
+export async function loadOemCodePage(
+  organizationId: string,
+  make: string,
+  code: string,
+  vehicleId: string | null | undefined,
+  locale: string,
+  options: { fixes?: boolean } = {}
+): Promise<FaultCodePageData & { oem: NonNullable<FaultCodePageData['oem']> }> {
+  const vehicle = await vehicleForCodes(organizationId, vehicleId)
+  const connected = await faultCodesConnected(organizationId)
+  const fixCode = oemFixCode(make, code)
+  const [answer, jobs, fixes, oemMakes, research, waveforms] = await Promise.all([
+    oemCodeFor(organizationId, make, code, vehicle, locale),
+    jobsWithCode(organizationId, code, 20, make),
+    options.fixes && connected
+      ? fixesFor(organizationId, fixCode)
+      : Promise.resolve<CodeFixes | null>(null),
+    connected ? oemMakesFor(organizationId) : Promise.resolve([]),
+    options.fixes && connected
+      ? researchFor(organizationId, fixCode)
+      : Promise.resolve<ResearchState | null>(null),
+    options.fixes && connected
+      ? waveformsFor(organizationId, fixCode)
+      : Promise.resolve<CodeWaveforms | null>(null),
+  ])
+  const makeName = nameOfMake(make)
+  return {
+    code,
+    connected,
+    connectable: getManifest('torqvoice-codes') !== null,
+    available: answer.available,
+    detail: oemDetail(code, makeName, answer.meanings[0] ?? null),
+    jobs,
+    fixes,
+    report: null,
+    vehicle: codeVehicle(vehicle),
+    oemMakes,
+    research,
+    waveforms,
+    oem: { make, makeName, fixCode, served: answer.served, meanings: answer.meanings },
   }
   }
 }
 }

+ 436 - 10
src/features/fault-codes/Lib/service.server.ts

@@ -5,7 +5,16 @@ import { getManifest, listManifests } from '@/integrations/registry'
 import { FAULT_CODES_CAPABILITY } from '@/integrations/torqvoice-codes/manifest'
 import { FAULT_CODES_CAPABILITY } from '@/integrations/torqvoice-codes/manifest'
 import { loadConnection } from '@/features/integrations/Lib/connections'
 import { loadConnection } from '@/features/integrations/Lib/connections'
 import { autoConnectFor } from '@/features/integrations/Lib/auto-connect'
 import { autoConnectFor } from '@/features/integrations/Lib/auto-connect'
-import { isManufacturerCode } from './codes'
+import {
+  isFaultCode,
+  isManufacturerCode,
+  isOemCode,
+  normalizeAnyMakeCode,
+  normalizeOemCode,
+  oemSourceFor,
+  vehicleCarriesMake,
+} from './codes'
+import { isMakeKey, makeName } from './makes'
 import { connectorAllowed } from '@/features/integrations/Lib/plan'
 import { connectorAllowed } from '@/features/integrations/Lib/plan'
 import type {
 import type {
   ConnectorContext,
   ConnectorContext,
@@ -16,7 +25,18 @@ import type {
   FaultCodeMeaning,
   FaultCodeMeaning,
   FaultCodeOwnFix,
   FaultCodeOwnFix,
   FaultCodeReportReason,
   FaultCodeReportReason,
+  FaultCodeOwnWaveform,
+  FaultCodeResearch,
+  FaultCodeResearchRefusal,
+  FaultCodeWaveformInput,
+  WaveformFileRole,
   FaultCodeVehicle,
   FaultCodeVehicle,
+  OemMeaning,
+  CodeImportChecked,
+  CodeImportCounts,
+  CodeImportInput,
+  CodeImportRefusal,
+  CodeImportStatus,
 } from '@/features/integrations/Lib/types'
 } from '@/features/integrations/Lib/types'
 import type {
 import type {
   CodeFixes,
   CodeFixes,
@@ -24,8 +44,12 @@ import type {
   FaultCodeJob,
   FaultCodeJob,
   FaultCodeView,
   FaultCodeView,
   MeaningSource,
   MeaningSource,
+  OemMeaningView,
+  OemSearchHit,
   OwnFix,
   OwnFix,
+  CodeWaveforms,
   ReportState,
   ReportState,
+  ResearchState,
   SharedFix,
   SharedFix,
 } from './types'
 } from './types'
 
 
@@ -194,11 +218,22 @@ export async function lookupMeanings(
   const loaded = await connect(organizationId)
   const loaded = await connect(organizationId)
   const answers = new Map<string, FaultCodeAnswer>()
   const answers = new Map<string, FaultCodeAnswer>()
   const answered = new Set<string>()
   const answered = new Set<string>()
-  if (loaded?.server.lookupCodes) {
+  // A manufacturer's own code (BMW's 2A82) is looked up in that make's data,
+  // one call per code; it only counts on a car of that make.
+  const source = oemSourceFor(vehicle?.make)
+  const oemCodes = source ? unique.filter((c) => !isFaultCode(c) && isOemCode(c)) : []
+  const obdCodes = unique.filter((c) => isFaultCode(c))
+  const oem = new Map<string, FaultCodeView>()
+  if (source && oemCodes.length > 0) {
+    for (const view of await oemViews(loaded, source, oemCodes, vehicle, locale)) {
+      oem.set(view.code, view)
+    }
+  }
+  if (loaded?.server.lookupCodes && obdCodes.length > 0) {
     const lookup = loaded.server.lookupCodes
     const lookup = loaded.server.lookupCodes
     const batches: string[][] = []
     const batches: string[][] = []
-    for (let i = 0; i < unique.length; i += LOOKUP_BATCH) {
-      batches.push(unique.slice(i, i + LOOKUP_BATCH))
+    for (let i = 0; i < obdCodes.length; i += LOOKUP_BATCH) {
+      batches.push(obdCodes.slice(i, i + LOOKUP_BATCH))
     }
     }
     const replies = await Promise.all(
     const replies = await Promise.all(
       batches.map((batch) =>
       batches.map((batch) =>
@@ -211,14 +246,395 @@ export async function lookupMeanings(
       for (const code of batches[i]) answered.add(code)
       for (const code of batches[i]) answered.add(code)
     })
     })
   }
   }
+  const oemAnswered = [...oem.values()].some((v) => v.state !== 'unavailable')
   return {
   return {
-    available: answered.size > 0,
-    results: unique.map((code) =>
-      viewOf(code, answers.get(code), answered.has(code), vehicle?.make)
+    available: answered.size > 0 || oemAnswered || (obdCodes.length === 0 && oem.size === 0),
+    results: unique.map(
+      (code) => oem.get(code) ?? viewOf(code, answers.get(code), answered.has(code), vehicle?.make)
     ),
     ),
   }
   }
 }
 }
 
 
+/** The text of a manufacturer meaning to show: its English when the reader's language is English and the manufacturer has one. */
+export function oemText(meaning: OemMeaning, locale: string): { text: string; lang: string } {
+  if (locale.toLowerCase().startsWith('en') && meaning.textEn)
+    return { text: meaning.textEn, lang: 'en' }
+  return { text: meaning.text, lang: meaning.textLang }
+}
+
+/**
+ * Manufacturer codes on a car of that make, for lists and findings: the
+ * meaning that fits the car (the service puts it first), or the first one.
+ */
+async function oemViews(
+  loaded: Loaded | null,
+  source: string,
+  codes: string[],
+  vehicle: FaultCodeVehicle | null,
+  locale: string
+): Promise<FaultCodeView[]> {
+  const lookup = loaded?.server.oemCode
+  if (!loaded || !lookup) return codes.map((code) => emptyView(code, 'unavailable'))
+  const replies = await Promise.all(
+    codes.map((code) =>
+      attempt(() =>
+        lookup(loaded.ctx, { source, code, engine: vehicle?.engine ?? undefined, locale })
+      )
+    )
+  )
+  return codes.map((code, i) => {
+    const reply = replies[i]
+    if (!reply.ok) return emptyView(code, 'unavailable')
+    const meaning = reply.value?.meanings[0]
+    if (!meaning) return { ...emptyView(code, 'unknown'), range: 'manufacturer' as const }
+    // Our own title reads better than the control unit's text, when there is one.
+    const shown = meaning.title
+      ? { text: meaning.title, lang: meaning.lang ?? 'en' }
+      : oemText(meaning, locale)
+    return {
+      ...emptyView(code, 'found'),
+      range: 'manufacturer' as const,
+      title: shown.text,
+      lang: shown.lang,
+      source: 'manufacturer' as const,
+      make: vehicle?.make ?? null,
+    }
+  })
+}
+
+/** Where this workshop's code imports are kept, one link per import. */
+const IMPORT_LINK = 'fault-code-import'
+
+export type CodeImportCheck =
+  | { ok: true; counts: CodeImportCounts; rows: CodeImportChecked[] }
+  | { ok: false; reason: CodeImportRefusal | 'unavailable' }
+
+/** What each row of an import file would do for the make, keeping nothing. */
+export async function checkCodeImportFor(
+  organizationId: string,
+  input: Omit<CodeImportInput, 'workshopRef' | 'source' | 'fileName'>
+): Promise<CodeImportCheck> {
+  const loaded = await connect(organizationId)
+  if (!loaded?.server.checkCodeImport) return { ok: false, reason: 'unavailable' }
+  const check = loaded.server.checkCodeImport
+  const reply = await attempt(() => check(loaded.ctx, input))
+  if (!reply.ok) return { ok: false, reason: 'unavailable' }
+  return reply.value
+}
+
+/** Sends an import's new and changed codes for review, and keeps its id for its status. */
+export async function sendCodeImportFor(
+  organizationId: string,
+  input: Omit<CodeImportInput, 'workshopRef'>
+): Promise<
+  | { ok: true; id: string; counts: CodeImportCounts }
+  | { ok: false; reason: CodeImportRefusal | 'unavailable' }
+> {
+  const loaded = await connect(organizationId)
+  if (!loaded?.server.sendCodeImport) return { ok: false, reason: 'unavailable' }
+  const send = loaded.server.sendCodeImport
+  const reply = await attempt(() => send(loaded.ctx, { ...input, workshopRef: organizationId }))
+  if (!reply.ok) return { ok: false, reason: 'unavailable' }
+  if (!reply.value.ok) return reply.value
+  await loaded.ctx.links.set(IMPORT_LINK, reply.value.id, {
+    remoteId: reply.value.id,
+    metadata: { make: input.make, sentAt: new Date().toISOString() },
+  })
+  return reply.value
+}
+
+/** This workshop's imports, newest first, with how their codes stand. */
+export async function codeImportsFor(organizationId: string): Promise<CodeImportStatus[]> {
+  const loaded = await connect(organizationId)
+  const status = loaded?.server.codeImportStatus
+  if (!loaded || !status) return []
+  const { links } = loaded.ctx
+  const ids = [...(await links.remoteIds(IMPORT_LINK))]
+  if (ids.length === 0) return []
+  const reply = await attempt(() => status(loaded.ctx, ids))
+  if (!reply.ok) return []
+  const answered = new Set(reply.value.map((i) => i.id))
+  await Promise.all(
+    ids.filter((id) => !answered.has(id)).map((id) => links.remove(IMPORT_LINK, id))
+  )
+  return reply.value.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+}
+
+/** Where this workshop's shared waveforms are kept, one link per waveform under its code. */
+export function waveformLink(code: string): string {
+  return `fault-code-waveform:${code}`
+}
+
+/**
+ * A code's waveforms for its page: the published ones from other workshops, and this
+ * workshop's own with how their review stands. One the service no longer has is forgotten.
+ */
+export async function waveformsFor(organizationId: string, code: string): Promise<CodeWaveforms> {
+  const none: CodeWaveforms = { available: false, published: [], own: [] }
+  const loaded = await connect(organizationId)
+  if (!loaded?.server.publishedWaveforms) return none
+  const { links } = loaded.ctx
+  const ownIds = [...(await links.remoteIds(waveformLink(code)))]
+  const listing = loaded.server.publishedWaveforms
+  const status = loaded.server.waveformStatus
+  const [published, own] = await Promise.all([
+    attempt(() => listing(loaded.ctx, { code })),
+    ownIds.length > 0 && status
+      ? attempt(() => status(loaded.ctx, ownIds))
+      : Promise.resolve({ ok: true as const, value: [] as FaultCodeOwnWaveform[] }),
+  ])
+  if (!published.ok) return none
+  const mine = new Set(ownIds)
+  let ownWaveforms: FaultCodeOwnWaveform[] = []
+  if (own.ok) {
+    const answered = new Set(own.value.map((w) => w.id))
+    await Promise.all(
+      ownIds.filter((id) => !answered.has(id)).map((id) => links.remove(waveformLink(code), id))
+    )
+    ownWaveforms = own.value.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+  }
+  return {
+    available: true,
+    published: published.value.filter((w) => !mine.has(w.id)),
+    own: ownWaveforms,
+  }
+}
+
+export type ShareWaveformResult =
+  | { ok: true; waveform: FaultCodeOwnWaveform }
+  | { ok: false; reason: FaultCodeFixRefusal | 'unavailable' }
+
+/** Shares a scope recording with the service and keeps its id, so the page shows its review. */
+export async function shareWaveform(
+  organizationId: string,
+  input: Omit<FaultCodeWaveformInput, 'workshopRef'>
+): Promise<ShareWaveformResult> {
+  const loaded = await connect(organizationId)
+  if (!loaded?.server.shareWaveform) return { ok: false, reason: 'unavailable' }
+  const share = loaded.server.shareWaveform
+  const reply = await attempt(() => share(loaded.ctx, { ...input, workshopRef: organizationId }))
+  if (!reply.ok) return { ok: false, reason: 'unavailable' }
+  if (!reply.value.ok) return { ok: false, reason: reply.value.reason }
+  const id = reply.value.id
+  await loaded.ctx.links.set(waveformLink(input.code), id, {
+    remoteId: id,
+    metadata: { code: input.code, sentAt: new Date().toISOString() },
+  })
+  const status = loaded.server.waveformStatus
+  const fresh = status ? await attempt(() => status(loaded.ctx, [id])) : null
+  const found = fresh?.ok ? fresh.value.find((w) => w.id === id) : undefined
+  if (found) return { ok: true, waveform: found }
+  return {
+    ok: true,
+    waveform: {
+      id,
+      code: input.code,
+      meaning: input.meaning ?? null,
+      kind: input.kind,
+      title: input.title,
+      condition: input.condition ?? null,
+      note: input.note ?? null,
+      lang: input.lang,
+      vehicle: input.vehicle ?? null,
+      preview: null,
+      files: input.files.map((f) => ({
+        role: f.role,
+        name: f.name,
+        size: f.blob.size,
+        width: null,
+        height: null,
+      })),
+      createdAt: new Date().toISOString(),
+      status: 'pending',
+      reason: null,
+      reviewedAt: null,
+    },
+  }
+}
+
+/** Withdraws a waveform this workshop shared; only one in its link store. */
+export async function withdrawSharedWaveform(
+  organizationId: string,
+  code: string,
+  id: string
+): Promise<'withdrawn' | 'not_found' | 'unavailable'> {
+  const loaded = await connect(organizationId)
+  if (!loaded?.server.withdrawWaveform) return 'unavailable'
+  const { links } = loaded.ctx
+  const ids = await links.remoteIds(waveformLink(code))
+  if (!ids.has(id)) return 'not_found'
+  const withdraw = loaded.server.withdrawWaveform
+  const reply = await attempt(() => withdraw(loaded.ctx, { id, workshopRef: organizationId }))
+  if (!reply.ok) return 'unavailable'
+  await links.remove(waveformLink(code), id)
+  return 'withdrawn'
+}
+
+/** One file of a waveform, streamed from the service: a published one's, or this workshop's own. */
+export async function sharedWaveformFile(
+  organizationId: string,
+  query: { id: string; role: WaveformFileRole; size: 'full' | 'thumb' }
+) {
+  const loaded = await connect(organizationId)
+  if (!loaded?.server.waveformFile) return null
+  const file = loaded.server.waveformFile
+  const reply = await attempt(() => file(loaded.ctx, { ...query, workshopRef: organizationId }))
+  return reply.ok ? reply.value : null
+}
+
+/** A code's researched fixes for its page (P0420, BMW-2A82), and whether research is on. */
+export async function researchFor(organizationId: string, code: string): Promise<ResearchState> {
+  const loaded = await connect(organizationId)
+  const read = loaded?.server.researchFor
+  if (!loaded || !read) return { available: false, items: [] }
+  const reply = await attempt(() => read(loaded.ctx, { code }))
+  return reply.ok
+    ? { available: reply.value.available, items: reply.value.research }
+    : { available: false, items: [] }
+}
+
+export type ResearchOutcome =
+  | { outcome: 'done'; research: FaultCodeResearch }
+  | { outcome: FaultCodeResearchRefusal | 'failed' }
+
+/** Researches a code, or one meaning of a make's code, for this workshop. */
+export async function requestResearch(
+  organizationId: string,
+  input: { code: string; meaning?: string; vehicle: FaultCodeVehicle | null }
+): Promise<ResearchOutcome> {
+  const loaded = await connect(organizationId)
+  const ask = loaded?.server.research
+  if (!loaded || !ask) return { outcome: 'unavailable' }
+  const reply = await attempt(() =>
+    ask(loaded.ctx, {
+      code: input.code,
+      meaning: input.meaning,
+      vehicle: input.vehicle ?? undefined,
+      workshopRef: organizationId,
+    })
+  )
+  if (!reply.ok) return { outcome: 'failed' }
+  return reply.value.ok
+    ? { outcome: 'done', research: reply.value.research }
+    : { outcome: reply.value.reason }
+}
+
+/** "Did this fix it?" for a research, once per workshop. */
+export async function rateResearch(
+  organizationId: string,
+  id: string,
+  helpful: boolean
+): Promise<boolean> {
+  const loaded = await connect(organizationId)
+  const rate = loaded?.server.rateResearch
+  if (!loaded || !rate) return false
+  const reply = await attempt(() => rate(loaded.ctx, { id, helpful, workshopRef: organizationId }))
+  return reply.ok && reply.value
+}
+
+/** The makes whose own codes the service answers for ("bmw"); none when it serves none. */
+export async function oemMakesFor(organizationId: string): Promise<string[]> {
+  const loaded = await connect(organizationId)
+  const list = loaded?.server.oemMakes
+  if (!loaded || !list) return []
+  const reply = await attempt(() => list(loaded.ctx))
+  return reply.ok ? reply.value.filter(isMakeKey) : []
+}
+
+/** A make's meanings as its code's page shows them, the one fitting the car first. */
+export function oemMeaningViews(meanings: OemMeaning[], locale: string): OemMeaningView[] {
+  return meanings.map((m) => {
+    const own = oemText(m, locale)
+    return {
+      id: m.id,
+      title: m.title ?? own.text,
+      titleLang: m.title ? (m.lang ?? 'en') : own.lang,
+      ownTitle: Boolean(m.title),
+      text: own.text,
+      textLang: own.lang,
+      textEn: m.textEn,
+      description: m.description,
+      sections: m.sections,
+      causes: m.causes,
+      modules: m.modules,
+      vehicles: m.vehicles,
+      engines: m.engines,
+      matches: m.matches,
+    }
+  })
+}
+
+/**
+ * A make's own code on its page: whether the service serves the make, and
+ * every meaning the code has there. `available` is false when the service
+ * could not be reached.
+ */
+export async function oemCodeFor(
+  organizationId: string,
+  make: string,
+  code: string,
+  vehicle: FaultCodeVehicle | null,
+  locale: string
+): Promise<{ available: boolean; served: boolean; meanings: OemMeaningView[] }> {
+  const loaded = await connect(organizationId)
+  const lookup = loaded?.server.oemCode
+  if (!loaded || !lookup) return { available: false, served: false, meanings: [] }
+  const reply = await attempt(() =>
+    lookup(loaded.ctx, { source: make, code, engine: vehicle?.engine ?? undefined, locale })
+  )
+  if (!reply.ok) return { available: false, served: false, meanings: [] }
+  if (!reply.value) return { available: true, served: false, meanings: [] }
+  return { available: true, served: true, meanings: oemMeaningViews(reply.value.meanings, locale) }
+}
+
+/**
+ * A make's own codes for what was typed (BMW's 283700), for the lookup page
+ * and the search palette: one hit per make the service serves that has it.
+ * Too short to be a whole code, or not one at all, finds nothing.
+ */
+export async function searchOemCodes(
+  organizationId: string,
+  query: string,
+  locale: string,
+  served?: string[]
+): Promise<OemSearchHit[]> {
+  const loaded = await connect(organizationId)
+  // One call across every make, where the service can; else one call per make it serves.
+  if (loaded?.server.findOemCode) {
+    const find = loaded.server.findOemCode
+    const anyCode = normalizeAnyMakeCode(query)
+    if (!anyCode || anyCode.length < 4) return []
+    const reply = await attempt(() => find(loaded.ctx, { code: anyCode, locale }))
+    if (!reply.ok) return []
+    return reply.value.map((h) => ({
+      make: h.make,
+      makeName: makeName(h.make),
+      code: h.code,
+      title: h.title ?? h.code,
+      meanings: h.meanings,
+    }))
+  }
+  const code = normalizeOemCode(query)
+  if (!code || code.length < 4) return []
+  const makes = served ?? (await oemMakesFor(organizationId))
+  const found = await Promise.all(
+    makes.map(async (make) => {
+      const answer = await oemCodeFor(organizationId, make, code, null, locale)
+      const first = answer.meanings[0]
+      return first
+        ? {
+            make,
+            makeName: makeName(make),
+            code,
+            title: first.title,
+            meanings: answer.meanings.length,
+          }
+        : null
+    })
+  )
+  return found.filter((h): h is OemSearchHit => h !== null)
+}
+
 /** One code with its description, causes and related codes. */
 /** One code with its description, causes and related codes. */
 export async function codeDetailFor(
 export async function codeDetailFor(
   organizationId: string,
   organizationId: string,
@@ -280,9 +696,11 @@ export async function searchServiceCodes(
 export async function jobsWithCode(
 export async function jobsWithCode(
   organizationId: string,
   organizationId: string,
   code: string,
   code: string,
-  take = 20
+  take = 20,
+  /** For a make's own code: only jobs on cars that carry that make's codes (a MINI carries BMW's). */
+  make?: string
 ): Promise<FaultCodeJob[]> {
 ): Promise<FaultCodeJob[]> {
-  const rows = await db.vehicleFinding.findMany({
+  const found = await db.vehicleFinding.findMany({
     where: { faultCode: code, vehicle: { organizationId } },
     where: { faultCode: code, vehicle: { organizationId } },
     select: {
     select: {
       id: true,
       id: true,
@@ -295,8 +713,12 @@ export async function jobsWithCode(
       vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
       vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
     },
     },
     orderBy: { createdAt: 'desc' },
     orderBy: { createdAt: 'desc' },
-    take,
+    // Room to drop other makes' jobs before keeping `take`: a make is written many ways.
+    take: make ? Math.max(take * 10, 200) : take,
   })
   })
+  const rows = make
+    ? found.filter((r) => vehicleCarriesMake(make, r.vehicle.make)).slice(0, take)
+    : found
   return rows.map((r) => ({
   return rows.map((r) => ({
     findingId: r.id,
     findingId: r.id,
     serviceRecordId: r.serviceRecord?.id ?? null,
     serviceRecordId: r.serviceRecord?.id ?? null,
@@ -473,6 +895,7 @@ export function fixLink(code: string): string {
 function sharedFixOf(fix: FaultCodeFix): SharedFix {
 function sharedFixOf(fix: FaultCodeFix): SharedFix {
   return {
   return {
     id: fix.id,
     id: fix.id,
+    meaning: fix.meaning,
     lang: fix.lang,
     lang: fix.lang,
     text: fix.text,
     text: fix.text,
     vehicle: fix.vehicle
     vehicle: fix.vehicle
@@ -511,6 +934,7 @@ export async function shareFix(
   organizationId: string,
   organizationId: string,
   input: {
   input: {
     code: string
     code: string
+    meaning?: string
     text: string
     text: string
     lang: string
     lang: string
     vehicle: FaultCodeVehicle | null
     vehicle: FaultCodeVehicle | null
@@ -523,6 +947,7 @@ export async function shareFix(
   const reply = await attempt(() =>
   const reply = await attempt(() =>
     share(loaded.ctx, {
     share(loaded.ctx, {
       code: input.code,
       code: input.code,
+      meaning: input.meaning,
       text: input.text,
       text: input.text,
       lang: input.lang,
       lang: input.lang,
       vehicle: input.vehicle ?? undefined,
       vehicle: input.vehicle ?? undefined,
@@ -547,6 +972,7 @@ export async function shareFix(
     ok: true,
     ok: true,
     fix: {
     fix: {
       id,
       id,
+      meaning: input.meaning ?? null,
       lang: input.lang,
       lang: input.lang,
       text: input.text,
       text: input.text,
       vehicle: input.vehicle
       vehicle: input.vehicle

+ 89 - 1
src/features/fault-codes/Lib/types.ts

@@ -1,4 +1,10 @@
-import type { FaultCodeReportReason, FaultCodeSectionKind } from '@/features/integrations/Lib/types'
+import type {
+  FaultCodeOwnWaveform,
+  FaultCodeReportReason,
+  FaultCodeResearch,
+  FaultCodeSectionKind,
+  FaultCodeWaveform,
+} from '@/features/integrations/Lib/types'
 
 
 /**
 /**
  * What the screens get about a code, in one shape. Plain data, so it crosses
  * What the screens get about a code, in one shape. Plain data, so it crosses
@@ -58,6 +64,60 @@ export interface FaultCodeDetailView extends FaultCodeView {
 }
 }
 
 
 /** A job in this workshop where the code was read. */
 /** A job in this workshop where the code was read. */
+/**
+ * One meaning of a make's own code as its page shows it: our title and text
+ * when written, BMW's own words always, and where BMW uses it.
+ */
+export interface OemMeaningView {
+  id: string
+  /** Our title, else the make's text (its English where the reader reads English). */
+  title: string
+  titleLang: string
+  /** Whether `title` is ours; the make's own text then reads under it. */
+  ownTitle: boolean
+  /** The make's text as its units give it, and its language. */
+  text: string
+  textLang: string
+  textEn: string | null
+  description: string | null
+  sections: FaultCodeSection[]
+  causes: { key: string; likelihood: 'low' | 'medium' | 'high'; label: string }[]
+  modules: { ref: string; family: string | null; name: string | null }[]
+  vehicles: {
+    chassis: string
+    model: string | null
+    body: string | null
+    yearFrom: number | null
+    yearTo: number | null
+  }[]
+  engines: string[]
+  /** One of its units fits the job's car. */
+  matches: boolean
+}
+
+/** What a make's own code adds to a code's page: its make and every meaning. */
+export interface OemPageData {
+  /** The make's data the code is from ("bmw") and how people write it ("BMW"). */
+  make: string
+  makeName: string
+  /** The code as shared fixes keep it: BMW-2A82. */
+  fixCode: string
+  /** The service serves this make's codes (it may still not know this one). */
+  served: boolean
+  /** The meaning fitting the car first. */
+  meanings: OemMeaningView[]
+}
+
+/** A make's code in a search: where it opens, and the meaning to name it by. */
+export interface OemSearchHit {
+  make: string
+  makeName: string
+  code: string
+  title: string
+  /** How many meanings it has; the title is the first. */
+  meanings: number
+}
+
 export interface FaultCodeJob {
 export interface FaultCodeJob {
   findingId: string
   findingId: string
   serviceRecordId: string | null
   serviceRecordId: string | null
@@ -105,6 +165,8 @@ export interface FixPhoto {
  */
  */
 export interface SharedFix {
 export interface SharedFix {
   id: string
   id: string
+  /** For a make's own code, the meaning the fix is for; null when it is for the code as a whole. */
+  meaning: string | null
   /** The language it was written in. */
   /** The language it was written in. */
   lang: string
   lang: string
   text: string
   text: string
@@ -154,4 +216,30 @@ export interface FaultCodePageData {
   fixes: CodeFixes | null
   fixes: CodeFixes | null
   report: ReportState | null
   report: ReportState | null
   vehicle: CodeVehicle | null
   vehicle: CodeVehicle | null
+  /** The makes whose own codes the service serves ("bmw"), for what the page says it covers. */
+  oemMakes?: string[]
+  /** Only on a make's own code (BMW's 2A82). */
+  oem?: OemPageData
+  /** On the code's own page: whether research can be asked for, and each meaning's research. */
+  research?: ResearchState | null
+  /** On the code's own page: shared scope recordings. */
+  waveforms?: CodeWaveforms | null
+  /** Makes that give an OBD-shaped code a meaning of their own (Ford's P1000), the car's first. */
+  makeCodes?: OemSearchHit[]
+}
+
+/**
+ * Waveforms on a code's page: whether the service could be asked, the published ones, and
+ * this workshop's own with how their review stands.
+ */
+export interface CodeWaveforms {
+  available: boolean
+  published: FaultCodeWaveform[]
+  own: FaultCodeOwnWaveform[]
+}
+
+/** Researched fixes on a code's page: whether one can be asked for, and those there are. */
+export interface ResearchState {
+  available: boolean
+  items: FaultCodeResearch[]
 }
 }

+ 106 - 0
src/features/fault-codes/Lib/waveformCsv.ts

@@ -0,0 +1,106 @@
+import type { WaveformPreview } from '@/features/integrations/Lib/types'
+
+/**
+ * A scope's CSV export drawn small, read in the browser the moment it is dropped, so the
+ * technician sees the waveform before sharing it. The same reading as the code service's
+ * (crates/core/src/waveform.rs), which draws the one other workshops see: a header row, an
+ * optional units row ("(ms),(V)"), the samples cut into equal time slices with each channel's
+ * lowest and highest value per slice. `;` or tab between values means a decimal comma.
+ */
+export const PREVIEW_SLICES = 400
+const MAX_CHANNELS = 8
+
+function cells(line: string, sep: string): string[] {
+  return line.split(sep).map((c) =>
+    c
+      .trim()
+      .replace(/^"+|"+$/g, '')
+      .trim()
+  )
+}
+
+function number(cell: string | undefined, decimalComma: boolean): number | null {
+  if (cell === undefined || cell === '') return null
+  const value = Number(decimalComma ? cell.replace(',', '.') : cell)
+  return Number.isFinite(value) ? value : null
+}
+
+/** The preview of a CSV export, or null when it is not one. */
+export function previewCsv(text: string): WaveformPreview | null {
+  const lines = text
+    .replace(/^/, '')
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .filter((l) => l !== '')
+  const header = lines.shift()
+  if (!header) return null
+  const sep = header.includes(';') ? ';' : header.includes('\t') ? '\t' : ','
+  const decimalComma = sep !== ','
+  const names = cells(header, sep)
+  if (names.length < 2) return null
+
+  let units: string[] = []
+  if (lines[0]?.startsWith('(') || lines[0]?.startsWith('"(')) {
+    units = cells(lines.shift() as string, sep).map((c) => c.replace(/^\(/, '').replace(/\)$/, ''))
+  }
+  const count = Math.min(names.length - 1, MAX_CHANNELS)
+  const times: number[] = []
+  const values: (number | null)[][] = []
+  for (const row of lines) {
+    const c = cells(row, sep)
+    const t = number(c[0], decimalComma)
+    if (t === null) continue
+    times.push(t)
+    values.push(Array.from({ length: count }, (_, i) => number(c[i + 1], decimalComma)))
+  }
+  if (times.length < 2) return null
+  let start = Number.POSITIVE_INFINITY
+  let end = Number.NEGATIVE_INFINITY
+  for (const t of times) {
+    if (t < start) start = t
+    if (t > end) end = t
+  }
+  if (!(end > start)) return null
+
+  const min = Array.from({ length: count }, () =>
+    new Array<number | null>(PREVIEW_SLICES).fill(null)
+  )
+  const max = Array.from({ length: count }, () =>
+    new Array<number | null>(PREVIEW_SLICES).fill(null)
+  )
+  for (let i = 0; i < times.length; i++) {
+    const slice = Math.min(
+      PREVIEW_SLICES - 1,
+      Math.floor(((times[i] - start) / (end - start)) * PREVIEW_SLICES)
+    )
+    for (let ch = 0; ch < count; ch++) {
+      const v = values[i][ch]
+      if (v === null) continue
+      const lo = min[ch][slice]
+      const hi = max[ch][slice]
+      min[ch][slice] = lo === null ? v : Math.min(lo, v)
+      max[ch][slice] = hi === null ? v : Math.max(hi, v)
+    }
+  }
+  const channels = []
+  for (let ch = 0; ch < count; ch++) {
+    if (!min[ch].some((v) => v !== null)) continue
+    channels.push({ name: names[ch + 1], unit: units[ch + 1] ?? '', min: min[ch], max: max[ch] })
+  }
+  if (channels.length === 0) return null
+  return { timeUnit: units[0] ?? '', start, end, channels }
+}
+
+/** Which part of a shared waveform a dropped file is, by its name; null when none. */
+export function waveformRole(
+  name: string,
+  type: string
+): 'psdata' | 'pssettings' | 'csv' | 'image' | null {
+  const lower = name.toLowerCase()
+  if (lower.endsWith('.psdata')) return 'psdata'
+  if (lower.endsWith('.pssettings')) return 'pssettings'
+  if (lower.endsWith('.csv') || lower.endsWith('.txt')) return 'csv'
+  if (type.startsWith('image/') || /\.(jpe?g|png|webp|heic|heif|bmp|gif)$/.test(lower))
+    return 'image'
+  return null
+}

+ 145 - 6
src/features/fault-codes/Schema/faultCodeSchema.ts

@@ -1,5 +1,12 @@
 import { z } from 'zod'
 import { z } from 'zod'
-import { FAULT_STATUSES, normalizeFaultCode } from '../Lib/codes'
+import {
+  FAULT_STATUSES,
+  normalizeAnyMakeCode,
+  normalizeFaultCode,
+  normalizeFixCode,
+  normalizeOemCode,
+} from '../Lib/codes'
+import { isMakeKey } from '../Lib/makes'
 
 
 /** A code as typed or pasted, normalised to P0420; anything that is not a code is refused. */
 /** A code as typed or pasted, normalised to P0420; anything that is not a code is refused. */
 export const faultCodeField = z
 export const faultCodeField = z
@@ -14,8 +21,43 @@ export const faultCodeField = z
     return code
     return code
   })
   })
 
 
+/**
+ * An OBD code (P0420) or a manufacturer's own code (BMW's 2A82), normalised.
+ * Whether a manufacturer code counts on a car is the action's to decide.
+ */
+export const anyCodeField = z
+  .string()
+  .max(16)
+  .transform((value, ctx) => {
+    const code = normalizeFaultCode(value) ?? normalizeOemCode(value)
+    if (!code) {
+      ctx.addIssue({ code: 'custom', message: 'Not a fault code' })
+      return z.NEVER
+    }
+    return code
+  })
+
 export const faultStatusField = z.enum(FAULT_STATUSES)
 export const faultStatusField = z.enum(FAULT_STATUSES)
 
 
+/** A manufacturer code's page: the make's data ("bmw"), the code and maybe the job's car. */
+export const oemCodeSchema = z.object({
+  source: z.string().refine(isMakeKey, 'Not a make'),
+  code: z
+    .string()
+    .max(16)
+    .transform((value, ctx) => {
+      const code = normalizeAnyMakeCode(value)
+      if (!code) {
+        ctx.addIssue({ code: 'custom', message: 'Not a manufacturer code' })
+        return z.NEVER
+      }
+      return code
+    }),
+  vehicleId: z.string().min(1).max(64).nullish(),
+  /** The code's own page lists shared fixes; the sheet beside a job does not. */
+  fixes: z.boolean().optional(),
+})
+
 const id = z.string().min(1).max(64)
 const id = z.string().min(1).max(64)
 
 
 export const lookupFaultCodesSchema = z.object({
 export const lookupFaultCodesSchema = z.object({
@@ -40,7 +82,7 @@ export const addFaultCodeFindingsSchema = z.object({
   codes: z
   codes: z
     .array(
     .array(
       z.object({
       z.object({
-        code: faultCodeField,
+        code: anyCodeField,
         status: faultStatusField.nullish(),
         status: faultStatusField.nullish(),
         module: z.string().trim().max(80).nullish(),
         module: z.string().trim().max(80).nullish(),
       })
       })
@@ -96,18 +138,45 @@ const optionalText = (max: number) =>
  * the form. The vehicle comes from the job's vehicle when the page was
  * the form. The vehicle comes from the job's vehicle when the page was
  * opened from one, else from what was typed; never a plate or VIN.
  * opened from one, else from what was typed; never a plate or VIN.
  */
  */
+/** A shared fix's code: an OBD code (P0420) or a make's own with its make (BMW-2A82). */
+export const fixCodeField = z.string().transform((value, ctx) => {
+  const code = normalizeFixCode(value)
+  if (!code) {
+    ctx.addIssue({ code: 'custom', message: 'Not a fault code' })
+    return z.NEVER
+  }
+  return code
+})
+
 /** A draft's id: a UUID the share dialog makes, naming where a phone's photos wait. */
 /** A draft's id: a UUID the share dialog makes, naming where a phone's photos wait. */
 export const fixDraftId = z.string().uuid()
 export const fixDraftId = z.string().uuid()
 
 
-export const fixPhoneLinkSchema = z.object({ code: faultCodeField, draftId: fixDraftId })
+export const fixPhoneLinkSchema = z.object({
+  code: fixCodeField,
+  draftId: fixDraftId,
+  /** A waveform's phone code asks for one photo of the scope screen. */
+  purpose: z.enum(['fix', 'waveform']).optional(),
+})
+
+/** A waveform takes one picture of the scope screen. */
+export const MAX_WAVEFORM_PHOTOS = 1
 export const fixDraftSchema = z.object({ draftId: fixDraftId })
 export const fixDraftSchema = z.object({ draftId: fixDraftId })
 export const fixDraftPhotoSchema = z.object({
 export const fixDraftPhotoSchema = z.object({
   draftId: fixDraftId,
   draftId: fixDraftId,
   photoId: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/),
   photoId: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/),
 })
 })
 
 
+/** A meaning of a make's code, by its id in the make's files ("einlass-vanos"). */
+export const meaningIdField = z
+  .string()
+  .trim()
+  .max(64)
+  .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/)
+
 export const shareFixSchema = z.object({
 export const shareFixSchema = z.object({
-  code: faultCodeField,
+  code: fixCodeField,
+  /** For a make's own code, the meaning the fix is for. */
+  meaning: meaningIdField.optional(),
   text: z.string().trim().min(1).max(MAX_FIX_TEXT),
   text: z.string().trim().min(1).max(MAX_FIX_TEXT),
   vehicleId: id.optional(),
   vehicleId: id.optional(),
   draftId: fixDraftId.optional(),
   draftId: fixDraftId.optional(),
@@ -121,11 +190,81 @@ export const shareFixSchema = z.object({
     .pipe(z.number().int().min(1900).max(2100).optional()),
     .pipe(z.number().int().min(1900).max(2100).optional()),
 })
 })
 
 
+/** Researching a fix: the code (BMW-2A82 for a make's), its meaning for a make's, the job's car. */
+export const researchFaultCodeSchema = z.object({
+  code: fixCodeField,
+  meaning: meaningIdField.optional(),
+  vehicleId: id.nullish(),
+})
+
+export const rateResearchSchema = z.object({
+  id: z.string().uuid(),
+  helpful: z.boolean(),
+})
+
+/** The limits of a shared waveform's files, as the code service takes them. */
+export const MAX_WAVEFORM_RECORDING_BYTES = 10 * 1024 * 1024
+export const MAX_WAVEFORM_SETTINGS_BYTES = 1024 * 1024
+export const MAX_WAVEFORM_IMAGE_BYTES = 15 * 1024 * 1024
+export const WAVEFORM_CONDITIONS = [
+  'key_on',
+  'cranking',
+  'idle',
+  'rpm_2500',
+  'driving',
+  'under_load',
+] as const
+
+/** "Share a waveform": what it shows and how the engine ran; the files come as form parts. */
+export const shareWaveformSchema = z.object({
+  code: fixCodeField,
+  meaning: meaningIdField.optional(),
+  kind: z.enum(['good', 'faulty']),
+  title: z.string().trim().min(1).max(120),
+  condition: z.enum(WAVEFORM_CONDITIONS).optional(),
+  note: z.string().trim().max(MAX_FIX_TEXT).optional(),
+  vehicleId: id.optional(),
+  draftId: fixDraftId.optional(),
+  make: z
+    .string()
+    .trim()
+    .max(60)
+    .optional()
+    .transform((v) => v || undefined),
+  model: z
+    .string()
+    .trim()
+    .max(60)
+    .optional()
+    .transform((v) => v || undefined),
+  year: z
+    .string()
+    .trim()
+    .optional()
+    .transform((v) => (v ? Number(v) : undefined))
+    .pipe(z.number().int().min(1900).max(2100).optional()),
+})
+
+export const withdrawWaveformSchema = z.object({ code: fixCodeField, id: z.string().uuid() })
+
+/** "Help us improve": what the message is about, as a workshop picks it. */
+export const IMPROVE_TOPICS = ['makes', 'wrong', 'source', 'idea'] as const
+export const MAX_IMPROVE_TEXT = 5000
+
+/** A message to the Torqvoice team about fault codes, with the code on screen if any. */
+export const improveFaultCodesSchema = z.object({
+  topic: z.enum(IMPROVE_TOPICS).optional(),
+  message: z.string().trim().min(1).max(MAX_IMPROVE_TEXT),
+  /** The code as people read it ("P0420", "BMW 2A82"), when sent from a code's page. */
+  code: z.string().trim().max(40).optional(),
+  page: z.string().trim().max(300).optional(),
+})
+
 export const withdrawFixSchema = z.object({
 export const withdrawFixSchema = z.object({
-  code: faultCodeField,
+  code: fixCodeField,
   id: z.string().uuid(),
   id: z.string().uuid(),
 })
 })
 
 
 export const faultCodeJobsSchema = z.object({
 export const faultCodeJobsSchema = z.object({
-  code: faultCodeField,
+  code: anyCodeField,
 })
 })

+ 294 - 0
src/features/integrations/Lib/types.ts

@@ -617,6 +617,9 @@ export type FaultCodeSectionKind =
   | 'manufacturer'
   | 'manufacturer'
   | 'related'
   | 'related'
   | 'diagnosis'
   | 'diagnosis'
+  | 'common_issues'
+  | 'resolution'
+  | 'advisory'
   | 'other'
   | 'other'
 
 
 export interface FaultCodeMeaning {
 export interface FaultCodeMeaning {
@@ -657,6 +660,49 @@ export interface FaultCodeAnswer {
   meaning?: FaultCodeMeaning
   meaning?: FaultCodeMeaning
 }
 }
 
 
+/** A vehicle a manufacturer code's meaning applies to: the chassis, with its model when known. */
+export interface OemVehicle {
+  chassis: string
+  model: string | null
+  body: string | null
+  yearFrom: number | null
+  yearTo: number | null
+}
+
+/** One meaning of a manufacturer code, as the manufacturer words it. */
+export interface OemMeaning {
+  /** The meaning's id in the make's data ("einlass-vanos"). */
+  id: string
+  /** The text as the control unit has it. */
+  text: string
+  /** The language of `text` ("de" for most BMW units). */
+  textLang: string
+  /** The manufacturer's English text, when it has one. */
+  textEn: string | null
+  modules: { ref: string; family: string | null; name: string | null }[]
+  vehicles: OemVehicle[]
+  engines: string[]
+  /** One of its control units fits the vehicle asked about. */
+  matches: boolean
+  /**
+   * The service's own text for this meaning, as for an OBD-II code: a plain
+   * title, the description (in labelled parts when it is cut so) and likely
+   * causes, in `lang` (the language asked, or English). `lang` and `title`
+   * are null when nothing is written for this meaning yet.
+   */
+  lang: string | null
+  title: string | null
+  description: string | null
+  sections: { kind: FaultCodeSectionKind; text: string }[]
+  causes: { key: string; likelihood: 'low' | 'medium' | 'high'; label: string }[]
+}
+
+export interface OemCodeAnswer {
+  source: string
+  code: string
+  meanings: OemMeaning[]
+}
+
 export interface FaultCodeSearchHit {
 export interface FaultCodeSearchHit {
   code: string
   code: string
   title: FaultCodeText
   title: FaultCodeText
@@ -707,6 +753,8 @@ export interface FaultCodeFixPhoto {
 export interface FaultCodeFix {
 export interface FaultCodeFix {
   id: string
   id: string
   code: string
   code: string
+  /** For a make's own code, the meaning the fix is for ("einlass-vanos"). */
+  meaning: string | null
   /** The language the text was written in. */
   /** The language the text was written in. */
   lang: string
   lang: string
   text: string
   text: string
@@ -727,6 +775,8 @@ export interface FaultCodeOwnFix extends FaultCodeFix {
 
 
 export interface FaultCodeFixInput {
 export interface FaultCodeFixInput {
   code: string
   code: string
+  /** For a make's own code, the meaning the fix is for. */
+  meaning?: string
   text: string
   text: string
   lang: string
   lang: string
   vehicle?: FaultCodeVehicle
   vehicle?: FaultCodeVehicle
@@ -736,6 +786,173 @@ export interface FaultCodeFixInput {
   photos: Blob[]
   photos: Blob[]
 }
 }
 
 
+/**
+ * A researched fix for a code, or for one meaning of a make's code: what an AI found on the
+ * web, waiting for a person's check (pending) or checked (published); or a search that found
+ * nothing specific (missed); or one running right now.
+ */
+export interface FaultCodeResearch {
+  id: string
+  code: string
+  meaning: string | null
+  status: 'running' | 'pending' | 'published' | 'missed'
+  findings: {
+    title: string
+    meaning: string
+    commonIssues: string[]
+    steps: string[]
+    causes: { label: string; likelihood: 'low' | 'medium' | 'high' }[]
+    advice: string | null
+    sources: { url: string; title: string | null }[]
+  } | null
+  /** Why nothing was kept, for a miss. */
+  reason: string | null
+  /** How many times workshops asked for it. */
+  requests: number
+  helpful: number
+  notHelpful: number
+  createdAt: string
+}
+
+/** Why a research did not run: another one is running, the daily limit, or no research. */
+export type FaultCodeResearchRefusal = 'busy' | 'limited' | 'unavailable'
+
+/** Whether a shared waveform shows how it should look or what was wrong. */
+export type WaveformKind = 'good' | 'faulty'
+/** How the engine ran when it was recorded. */
+export type WaveformCondition =
+  | 'key_on'
+  | 'cranking'
+  | 'idle'
+  | 'rpm_2500'
+  | 'driving'
+  | 'under_load'
+/** The recording (PicoScope's .psdata), its settings, a CSV export, a picture of the screen. */
+export type WaveformFileRole = 'psdata' | 'pssettings' | 'csv' | 'image'
+
+/** A CSV export drawn small: each channel's lowest and highest value per time slice. */
+export interface WaveformPreview {
+  timeUnit: string
+  start: number
+  end: number
+  channels: { name: string; unit: string; min: (number | null)[]; max: (number | null)[] }[]
+}
+
+/** A scope recording a workshop shared for a code, known good or faulty. */
+export interface FaultCodeWaveform {
+  id: string
+  code: string
+  meaning: string | null
+  kind: WaveformKind
+  /** What was measured, in the workshop's words. */
+  title: string
+  condition: WaveformCondition | null
+  note: string | null
+  lang: string
+  vehicle: FaultCodeVehicle | null
+  preview: WaveformPreview | null
+  files: {
+    role: WaveformFileRole
+    name: string
+    size: number
+    width: number | null
+    height: number | null
+  }[]
+  createdAt: string
+}
+
+/** A waveform this workshop shared, with how its review stands. */
+export interface FaultCodeOwnWaveform extends FaultCodeWaveform {
+  status: 'pending' | 'published' | 'rejected'
+  reason: string | null
+  reviewedAt: string | null
+}
+
+export interface FaultCodeWaveformInput {
+  code: string
+  meaning?: string
+  kind: WaveformKind
+  title: string
+  condition?: WaveformCondition
+  note?: string
+  lang: string
+  vehicle?: FaultCodeVehicle
+  workshopRef: string
+  files: { role: WaveformFileRole; blob: Blob; name: string }[]
+}
+
+/** A make's code found without knowing the make: Ford's P1000, BMW's 2A82. */
+export interface OemFindHit {
+  make: string
+  code: string
+  meanings: number
+  title: string | null
+}
+
+/** One code from a workshop's import file, field by field. */
+export interface CodeImportRow {
+  code: string
+  title?: string
+  description?: string
+  commonIssues?: string
+  resolution?: string
+  advisory?: string
+  causes: { label: string; likelihood?: string }[]
+}
+
+/** Why a row of an import cannot go. */
+export type CodeImportProblem =
+  | 'code'
+  | 'duplicate'
+  | 'title'
+  | 'empty'
+  | 'too_long'
+  | 'likelihood'
+  | 'meanings'
+
+/** What a row would do: new for the make, a change to our text, the same, or a problem. */
+export interface CodeImportChecked {
+  n: number
+  code: string
+  state: 'new' | 'change' | 'same' | 'problem'
+  problem: CodeImportProblem | null
+  /** What a change sets: title, description, common_issues, resolution, advisory, causes. */
+  fields: string[]
+  addedCauses: number
+}
+
+export interface CodeImportCounts {
+  new: number
+  change: number
+  same: number
+  problem: number
+}
+
+export interface CodeImportInput {
+  /** The make as the service keys it: ford, land-rover. */
+  make: string
+  lang: string
+  rows: CodeImportRow[]
+  source?: string
+  fileName?: string
+  workshopRef: string
+}
+
+/** An import this workshop sent, with how its codes stand. */
+export interface CodeImportStatus {
+  id: string
+  make: string
+  lang: string
+  source: string | null
+  fileName: string | null
+  createdAt: string
+  counts: { new: number; change: number; pending: number; published: number; rejected: number }
+  reviewedAt: string | null
+}
+
+/** Why the service would not take an import; anything else is FaultCodesUnavailable. */
+export type CodeImportRefusal = 'paused' | 'too_large' | 'invalid' | 'nothing'
+
 /** Why the service would not take a fix; anything else is FaultCodesUnavailable. */
 /** Why the service would not take a fix; anything else is FaultCodesUnavailable. */
 export type FaultCodeFixRefusal = 'paused' | 'photo' | 'too_large' | 'invalid'
 export type FaultCodeFixRefusal = 'paused' | 'photo' | 'too_large' | 'invalid'
 
 
@@ -820,6 +1037,83 @@ export interface ConnectorServer {
     ctx: ConnectorContext,
     ctx: ConnectorContext,
     query: { code: string; vehicle?: FaultCodeVehicle; locale: string }
     query: { code: string; vehicle?: FaultCodeVehicle; locale: string }
   ): Promise<FaultCodeAnswer | null>
   ): Promise<FaultCodeAnswer | null>
+  /**
+   * A manufacturer's own code (BMW's 2A82) from one make's data (`source`,
+   * such as "bmw"): every meaning it has, with the control units, vehicles
+   * and engines behind each. Null when the service does not serve that
+   * make's codes, or does not know the code.
+   */
+  /** Whether research can be asked for, and each meaning's research of a code (P0420, BMW-2A82). */
+  researchFor?(
+    ctx: ConnectorContext,
+    query: { code: string }
+  ): Promise<{ available: boolean; research: FaultCodeResearch[] }>
+  /**
+   * Researches a code (or a meaning of a make's code) with AI and web search, or answers with
+   * what is known. Can take a minute or two.
+   */
+  research?(
+    ctx: ConnectorContext,
+    input: { code: string; meaning?: string; vehicle?: FaultCodeVehicle; workshopRef: string }
+  ): Promise<
+    { ok: true; research: FaultCodeResearch } | { ok: false; reason: FaultCodeResearchRefusal }
+  >
+  /** "Did this fix it?" for a research; false when there is no such research. */
+  rateResearch?(
+    ctx: ConnectorContext,
+    input: { id: string; helpful: boolean; workshopRef: string }
+  ): Promise<boolean>
+  /** Shares a scope recording for a code; it waits for a person's review. */
+  shareWaveform?(
+    ctx: ConnectorContext,
+    input: FaultCodeWaveformInput
+  ): Promise<{ ok: true; id: string } | { ok: false; reason: FaultCodeFixRefusal }>
+  /** Published waveforms for a code (P0420, BMW-2A82), newest first. */
+  publishedWaveforms?(ctx: ConnectorContext, query: { code: string }): Promise<FaultCodeWaveform[]>
+  /** This workshop's own waveforms among `ids`, with how their review stands. */
+  waveformStatus?(ctx: ConnectorContext, ids: string[]): Promise<FaultCodeOwnWaveform[]>
+  withdrawWaveform?(
+    ctx: ConnectorContext,
+    input: { id: string; workshopRef: string }
+  ): Promise<boolean>
+  /** One of a waveform's files, streamed; null when there is none to see. */
+  waveformFile?(
+    ctx: ConnectorContext,
+    query: { id: string; role: WaveformFileRole; size: 'full' | 'thumb'; workshopRef: string }
+  ): Promise<{
+    body: ReadableStream<Uint8Array>
+    length: number | null
+    contentType: string
+    disposition: string | null
+  } | null>
+  /** The makes whose own codes the service answers for ("bmw"); none while it serves none. */
+  oemMakes?(ctx: ConnectorContext): Promise<string[]>
+  /** Every make that has a code, for a search box that does not know the make. */
+  findOemCode?(
+    ctx: ConnectorContext,
+    query: { code: string; locale?: string }
+  ): Promise<OemFindHit[]>
+  /** What each row of an import file would do, keeping nothing. */
+  checkCodeImport?(
+    ctx: ConnectorContext,
+    input: Omit<CodeImportInput, 'workshopRef' | 'source' | 'fileName'>
+  ): Promise<
+    | { ok: true; counts: CodeImportCounts; rows: CodeImportChecked[] }
+    | { ok: false; reason: CodeImportRefusal }
+  >
+  /** Sends an import's new and changed codes for a person to review. */
+  sendCodeImport?(
+    ctx: ConnectorContext,
+    input: CodeImportInput
+  ): Promise<
+    { ok: true; id: string; counts: CodeImportCounts } | { ok: false; reason: CodeImportRefusal }
+  >
+  /** This workshop's imports among `ids`, with how their codes stand. */
+  codeImportStatus?(ctx: ConnectorContext, ids: string[]): Promise<CodeImportStatus[]>
+  oemCode?(
+    ctx: ConnectorContext,
+    query: { source: string; code: string; chassis?: string; engine?: string; locale?: string }
+  ): Promise<OemCodeAnswer | null>
   /** Codes by prefix ("p04") or by the words of their meaning ("lean bank 1"). */
   /** Codes by prefix ("p04") or by the words of their meaning ("lean bank 1"). */
   searchCodes?(
   searchCodes?(
     ctx: ConnectorContext,
     ctx: ConnectorContext,

+ 40 - 4
src/features/search/Components/SearchCommand.tsx

@@ -51,8 +51,10 @@ import { searchFaultCodes } from '@/features/fault-codes/Actions/searchFaultCode
 import {
 import {
   isManufacturerCode,
   isManufacturerCode,
   looksLikeFaultCode,
   looksLikeFaultCode,
+  looksLikeOemCode,
   normalizeFaultCode,
   normalizeFaultCode,
 } from '@/features/fault-codes/Lib/codes'
 } from '@/features/fault-codes/Lib/codes'
+import type { OemSearchHit } from '@/features/fault-codes/Lib/types'
 
 
 type Vehicle = {
 type Vehicle = {
   id: string
   id: string
@@ -127,6 +129,8 @@ interface FaultCodeResult {
   /** The code service answered. */
   /** The code service answered. */
   available: boolean
   available: boolean
   codes: { code: string; title: string }[]
   codes: { code: string; title: string }[]
+  /** A make's own code typed in full (BMW's 283700), from each make that has it. */
+  oem: OemSearchHit[]
   jobs: {
   jobs: {
     findingId: string
     findingId: string
     serviceRecordId: string | null
     serviceRecordId: string | null
@@ -137,7 +141,13 @@ interface FaultCodeResult {
   }[]
   }[]
 }
 }
 
 
-const NO_FAULT_CODES: FaultCodeResult = { query: '', available: false, codes: [], jobs: [] }
+const NO_FAULT_CODES: FaultCodeResult = {
+  query: '',
+  available: false,
+  codes: [],
+  oem: [],
+  jobs: [],
+}
 
 
 const EMPTY: SearchResult = {
 const EMPTY: SearchResult = {
   vehicles: [],
   vehicles: [],
@@ -568,7 +578,7 @@ export function SearchCommand({
   useEffect(() => {
   useEffect(() => {
     const term = query.trim()
     const term = query.trim()
     const seq = ++faultSeqRef.current
     const seq = ++faultSeqRef.current
-    if (!looksLikeFaultCode(term)) {
+    if (!looksLikeFaultCode(term) && !looksLikeOemCode(term)) {
       // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing results on a query that is not a code is intentional
       // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing results on a query that is not a code is intentional
       setFaultResults(NO_FAULT_CODES)
       setFaultResults(NO_FAULT_CODES)
       return
       return
@@ -587,6 +597,7 @@ export function SearchCommand({
             query: key,
             query: key,
             available: res.data.available,
             available: res.data.available,
             codes: res.data.codes,
             codes: res.data.codes,
+            oem: res.data.oem,
             jobs: res.data.jobs,
             jobs: res.data.jobs,
           }
           }
           faultCacheRef.current.set(key, next)
           faultCacheRef.current.set(key, next)
@@ -654,7 +665,8 @@ export function SearchCommand({
   const faultJobs = faultCurrent.jobs
   const faultJobs = faultCurrent.jobs
   const searchFaultWords =
   const searchFaultWords =
     faultCodesEnabled && !looksLikeFaultCode(query) && query.trim().length >= 3
     faultCodesEnabled && !looksLikeFaultCode(query) && query.trim().length >= 3
-  const hasFaultRows = faultCodeRows.length > 0 || faultJobs.length > 0
+  const faultOemRows = faultCodesEnabled ? faultCurrent.oem : []
+  const hasFaultRows = faultCodeRows.length > 0 || faultOemRows.length > 0 || faultJobs.length > 0
 
 
   const showWeak = !hasRecords && !hasFaultRows && strongGroups.length === 0
   const showWeak = !hasRecords && !hasFaultRows && strongGroups.length === 0
   const nothing =
   const nothing =
@@ -826,8 +838,32 @@ export function SearchCommand({
           </CommandGroup>
           </CommandGroup>
         )}
         )}
 
 
-        {hasQuery && faultCodeRows.length > 0 && (
+        {hasQuery && (faultCodeRows.length > 0 || faultOemRows.length > 0) && (
           <CommandGroup heading={tFault('group')}>
           <CommandGroup heading={tFault('group')}>
+            {faultOemRows.map((c) => (
+              <Row
+                key={`${c.make}:${c.code}`}
+                value={`fault-oem:${c.make}:${c.code}`}
+                onSelect={() =>
+                  pickRecord(`/fault-codes/oem/${c.make}/${c.code}`, c.code, c.title || undefined)
+                }
+                icon={ScanLine}
+                title={
+                  <>
+                    <span className="mr-2 font-mono font-semibold">{c.code}</span>
+                    <span className="mr-2 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary">
+                      {tCodes('oem.badge', { make: c.makeName })}
+                    </span>
+                    {c.title}
+                  </>
+                }
+                subtitle={
+                  c.meanings > 1
+                    ? tCodes('oem.otherMeanings', { count: c.meanings - 1 })
+                    : undefined
+                }
+              />
+            ))}
             {faultCodeRows.map((c) => (
             {faultCodeRows.map((c) => (
               <Row
               <Row
                 key={c.code}
                 key={c.code}

+ 2 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -36,6 +36,7 @@ import { getTireHotelSettings } from '@/features/tire-hotel/Lib/tireHotelSetting
 import { getStatusReportsForService } from '@/features/status-reports/Actions/getStatusReportsForService'
 import { getStatusReportsForService } from '@/features/status-reports/Actions/getStatusReportsForService'
 import { getServiceFindings } from '@/features/vehicles/Actions/findingActions'
 import { getServiceFindings } from '@/features/vehicles/Actions/findingActions'
 import { faultCodesConnected, seenBefore } from '@/features/fault-codes/Lib/service.server'
 import { faultCodesConnected, seenBefore } from '@/features/fault-codes/Lib/service.server'
+import { oemSourceFor } from '@/features/fault-codes/Lib/codes'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import {
 import {
   loadVehicleConditionMarks,
   loadVehicleConditionMarks,
@@ -409,6 +410,7 @@ export async function ServiceRecordPage({
       ? {
       ? {
           connected: await faultCodesConnected(organizationId),
           connected: await faultCodesConnected(organizationId),
           seenBefore: await seenBefore(organizationId, vehicleId, serviceId, jobCodes),
           seenBefore: await seenBefore(organizationId, vehicleId, serviceId, jobCodes),
+          oemSource: oemSourceFor(record.vehicle?.make),
         }
         }
       : { connected: false, seenBefore: {} }
       : { connected: false, seenBefore: {} }
   const openObservations = vehicleId
   const openObservations = vehicleId

+ 669 - 0
src/integrations/torqvoice-codes/server.ts

@@ -11,6 +11,19 @@ import {
   type FaultCodeReport,
   type FaultCodeReport,
   type FaultCodeReportStatus,
   type FaultCodeReportStatus,
   type FaultCodeSearchHit,
   type FaultCodeSearchHit,
+  type FaultCodeOwnWaveform,
+  type FaultCodeResearch,
+  type FaultCodeWaveform,
+  type FaultCodeWaveformInput,
+  type WaveformFileRole,
+  type FaultCodeResearchRefusal,
+  type CodeImportChecked,
+  type CodeImportCounts,
+  type CodeImportInput,
+  type CodeImportRefusal,
+  type CodeImportStatus,
+  type OemCodeAnswer,
+  type OemFindHit,
   FaultCodesUnavailable,
   FaultCodesUnavailable,
   type FaultCodeText,
   type FaultCodeText,
   type FaultCodeVehicle,
   type FaultCodeVehicle,
@@ -77,6 +90,9 @@ const SECTION_KINDS = [
   'manufacturer',
   'manufacturer',
   'related',
   'related',
   'diagnosis',
   'diagnosis',
+  'common_issues',
+  'resolution',
+  'advisory',
   'other',
   'other',
 ] as const
 ] as const
 
 
@@ -159,6 +175,7 @@ const fixVehicleSchema = z
 const fixSchema = z.object({
 const fixSchema = z.object({
   id: z.string(),
   id: z.string(),
   code: z.string(),
   code: z.string(),
+  meaning: z.string().nullish().catch(null),
   lang: z.string().catch('en'),
   lang: z.string().catch('en'),
   text: z.string(),
   text: z.string(),
   vehicle: fixVehicleSchema,
   vehicle: fixVehicleSchema,
@@ -264,6 +281,7 @@ export function mapFix(raw: z.infer<typeof fixSchema>): FaultCodeFix {
   return {
   return {
     id: raw.id,
     id: raw.id,
     code: raw.code,
     code: raw.code,
+    meaning: raw.meaning ?? null,
     lang: raw.lang,
     lang: raw.lang,
     text: raw.text,
     text: raw.text,
     vehicle: Object.keys(vehicle).length > 0 ? vehicle : null,
     vehicle: Object.keys(vehicle).length > 0 ? vehicle : null,
@@ -401,6 +419,642 @@ async function codeDetail(
   return answer.found ? answer : null
   return answer.found ? answer : null
 }
 }
 
 
+const oemReplySchema = z.object({
+  make: z.string(),
+  code: z.string(),
+  meanings: z
+    .array(
+      z.object({
+        id: z.string().catch(''),
+        text: z.string(),
+        text_lang: z.string().catch('de'),
+        text_en: z.string().nullish(),
+        modules: z
+          .array(
+            z.object({
+              ref: z.string(),
+              family: z.string().nullish(),
+              name: z.string().nullish(),
+            })
+          )
+          .catch([]),
+        vehicles: z
+          .array(
+            z.object({
+              chassis: z.string(),
+              model: z.string().nullish(),
+              body: z.string().nullish(),
+              year_from: z.number().int().nullish(),
+              year_to: z.number().int().nullish(),
+            })
+          )
+          .catch([]),
+        engines: z.array(z.string()).catch([]),
+        matches: z.boolean().catch(false),
+        // Our own text for the meaning; a part that does not read drops that part, never the meaning.
+        lang: z.string().nullish().catch(null),
+        title: z.string().nullish().catch(null),
+        description: z.string().nullish().catch(null),
+        sections: z
+          .array(z.object({ kind: z.enum(SECTION_KINDS).catch('other'), text: z.string() }))
+          .catch([]),
+        causes: z
+          .array(
+            z.object({
+              key: z.string(),
+              likelihood: z.enum(['low', 'medium', 'high']).catch('medium'),
+              label: z.string(),
+            })
+          )
+          .catch([]),
+      })
+    )
+    .catch([]),
+})
+
+const waveformSchema = z.object({
+  id: z.string(),
+  code: z.string(),
+  meaning: z.string().nullish(),
+  kind: z.enum(['good', 'faulty']),
+  title: z.string(),
+  condition: z
+    .enum(['key_on', 'cranking', 'idle', 'rpm_2500', 'driving', 'under_load'])
+    .nullish()
+    .catch(null),
+  note: z.string().nullish(),
+  lang: z.string().catch('en'),
+  vehicle: fixVehicleSchema,
+  preview: z
+    .object({
+      time_unit: z.string().catch(''),
+      start: z.number(),
+      end: z.number(),
+      channels: z.array(
+        z.object({
+          name: z.string(),
+          unit: z.string().catch(''),
+          min: z.array(z.number().nullable()),
+          max: z.array(z.number().nullable()),
+        })
+      ),
+    })
+    .nullish()
+    .catch(null),
+  files: z
+    .array(
+      z.object({
+        role: z.enum(['psdata', 'pssettings', 'csv', 'image']),
+        name: z.string(),
+        size: z.number().int(),
+        width: z.number().int().nullish(),
+        height: z.number().int().nullish(),
+      })
+    )
+    .catch([]),
+  created_at: z.string(),
+})
+
+const ownWaveformSchema = waveformSchema.extend({
+  status: z.enum(['pending', 'published', 'rejected']),
+  reason: z.string().nullish(),
+  reviewed_at: z.string().nullish(),
+})
+
+function waveformOf(raw: z.infer<typeof waveformSchema>): FaultCodeWaveform {
+  const v = raw.vehicle
+  const vehicle: FaultCodeVehicle = {}
+  if (v?.make) vehicle.make = v.make
+  if (v?.model) vehicle.model = v.model
+  if (v?.year) vehicle.year = v.year
+  if (v?.engine) vehicle.engine = v.engine
+  return {
+    id: raw.id,
+    code: raw.code,
+    meaning: raw.meaning ?? null,
+    kind: raw.kind,
+    title: raw.title,
+    condition: raw.condition ?? null,
+    note: raw.note ?? null,
+    lang: raw.lang,
+    vehicle: Object.keys(vehicle).length > 0 ? vehicle : null,
+    preview: raw.preview
+      ? {
+          timeUnit: raw.preview.time_unit,
+          start: raw.preview.start,
+          end: raw.preview.end,
+          channels: raw.preview.channels,
+        }
+      : null,
+    files: raw.files.map((f) => ({ ...f, width: f.width ?? null, height: f.height ?? null })),
+    createdAt: raw.created_at,
+  }
+}
+
+/** A recording, its settings, a CSV export and a picture: up to about 36 MB to send. */
+const WAVEFORM_TIMEOUT_MS = 120_000
+
+async function shareWaveform(
+  ctx: ConnectorContext,
+  input: FaultCodeWaveformInput
+): Promise<{ ok: true; id: string } | { ok: false; reason: FaultCodeFixRefusal }> {
+  const form = new FormData()
+  form.append(
+    'waveform',
+    new Blob(
+      [
+        JSON.stringify({
+          code: input.code,
+          meaning: input.meaning,
+          kind: input.kind,
+          title: input.title,
+          condition: input.condition,
+          note: input.note,
+          lang: input.lang,
+          vehicle: vehicleBody(input.vehicle),
+          workshop_ref: input.workshopRef,
+        }),
+      ],
+      { type: 'application/json' }
+    )
+  )
+  for (const file of input.files) form.append(file.role, file.blob, file.name)
+  const path = '/v1/waveforms'
+  const res = await send(ctx, path, {
+    method: 'POST',
+    headers: { Accept: 'application/json' },
+    body: form,
+    timeoutMs: WAVEFORM_TIMEOUT_MS,
+  })
+  const body: unknown = await res.json().catch(() => null)
+  if (res.ok) {
+    const parsed = fixReceiptSchema.safeParse(body)
+    if (!parsed.success) invalid('waveform receipt')
+    return { ok: true, id: parsed.data.id }
+  }
+  const detail = await logRefusal(ctx, path, res, body)
+  if (res.status === 503) return { ok: false, reason: 'paused' }
+  if (res.status === 413) return { ok: false, reason: 'too_large' }
+  if (res.status === 400) {
+    return { ok: false, reason: detail?.startsWith('the picture') ? 'photo' : 'invalid' }
+  }
+  throw new FaultCodesUnavailable(`HTTP ${res.status}${detail ? `: ${detail}` : ''}`)
+}
+
+async function publishedWaveforms(
+  ctx: ConnectorContext,
+  query: { code: string }
+): Promise<FaultCodeWaveform[]> {
+  const { status, body } = await call(
+    ctx,
+    `/v1/codes/${encodeURIComponent(query.code)}/waveforms?limit=20`,
+    { timeoutMs: LOOKUP_TIMEOUT_MS, accept: [400, 404] }
+  )
+  if (status === 404 || status === 400) return []
+  const parsed = z.object({ waveforms: z.array(waveformSchema) }).safeParse(body)
+  if (!parsed.success) invalid('waveform list')
+  return parsed.data.waveforms.map(waveformOf)
+}
+
+async function waveformStatus(
+  ctx: ConnectorContext,
+  ids: string[]
+): Promise<FaultCodeOwnWaveform[]> {
+  const unique = [...new Set(ids)].slice(0, 100)
+  if (unique.length === 0) return []
+  const { status, body } = await call(ctx, `/v1/waveforms?ids=${unique.join(',')}`, {
+    timeoutMs: LOOKUP_TIMEOUT_MS,
+    accept: [404],
+  })
+  if (status === 404) return []
+  const parsed = z.object({ waveforms: z.array(ownWaveformSchema) }).safeParse(body)
+  if (!parsed.success) invalid('waveform status')
+  return parsed.data.waveforms.map((w) => ({
+    ...waveformOf(w),
+    status: w.status,
+    reason: w.reason ?? null,
+    reviewedAt: w.reviewed_at ?? null,
+  }))
+}
+
+async function withdrawWaveform(
+  ctx: ConnectorContext,
+  input: { id: string; workshopRef: string }
+): Promise<boolean> {
+  const { status } = await call(ctx, `/v1/waveforms/${encodeURIComponent(input.id)}/withdraw`, {
+    method: 'POST',
+    body: { workshop_ref: input.workshopRef },
+    timeoutMs: LOOKUP_TIMEOUT_MS,
+    accept: [404],
+  })
+  return status === 204
+}
+
+async function waveformFile(
+  ctx: ConnectorContext,
+  query: { id: string; role: WaveformFileRole; size: 'full' | 'thumb'; workshopRef: string }
+): Promise<{
+  body: ReadableStream<Uint8Array>
+  length: number | null
+  contentType: string
+  disposition: string | null
+} | null> {
+  const params = new URLSearchParams({ workshop_ref: query.workshopRef })
+  if (query.size === 'thumb') params.set('size', 'thumb')
+  const path = `/v1/waveforms/${encodeURIComponent(query.id)}/files/${query.role}?${params}`
+  const res = await send(ctx, path, { timeoutMs: WAVEFORM_TIMEOUT_MS })
+  if (res.status === 404 || res.status === 400) return null
+  if (!res.ok || !res.body) {
+    const detail = await logRefusal(ctx, path, res, await res.json().catch(() => null))
+    throw new FaultCodesUnavailable(`HTTP ${res.status}${detail ? `: ${detail}` : ''}`)
+  }
+  const length = Number(res.headers.get('content-length'))
+  return {
+    body: res.body,
+    length: Number.isFinite(length) && length > 0 ? length : null,
+    contentType: res.headers.get('content-type') ?? 'application/octet-stream',
+    disposition: res.headers.get('content-disposition'),
+  }
+}
+
+const researchSchema = z.object({
+  id: z.string(),
+  code: z.string(),
+  meaning: z.string().nullish(),
+  status: z.enum(['running', 'pending', 'published', 'missed']),
+  findings: z
+    .object({
+      title: z.string(),
+      meaning: z.string(),
+      common_issues: z.array(z.string()).catch([]),
+      steps: z.array(z.string()).catch([]),
+      causes: z
+        .array(
+          z.object({
+            label: z.string(),
+            likelihood: z.enum(['low', 'medium', 'high']).catch('medium'),
+          })
+        )
+        .catch([]),
+      advice: z.string().nullish(),
+      sources: z.array(z.object({ url: z.string(), title: z.string().nullish() })).catch([]),
+    })
+    .nullish(),
+  reason: z.string().nullish(),
+  requests: z.number().int().catch(1),
+  helpful: z.number().int().catch(0),
+  not_helpful: z.number().int().catch(0),
+  created_at: z.string(),
+})
+
+function researchOf(raw: z.infer<typeof researchSchema>): FaultCodeResearch {
+  const f = raw.findings
+  return {
+    id: raw.id,
+    code: raw.code,
+    meaning: raw.meaning ?? null,
+    status: raw.status,
+    findings: f
+      ? {
+          title: f.title,
+          meaning: f.meaning,
+          commonIssues: f.common_issues,
+          steps: f.steps,
+          causes: f.causes,
+          advice: f.advice ?? null,
+          // Only web pages: a source is a link a technician opens.
+          sources: f.sources
+            .filter((src) => /^https?:\/\//i.test(src.url))
+            .map((src) => ({ url: src.url, title: src.title ?? null })),
+        }
+      : null,
+    reason: raw.reason ?? null,
+    requests: raw.requests,
+    helpful: raw.helpful,
+    notHelpful: raw.not_helpful,
+    createdAt: raw.created_at,
+  }
+}
+
+/** Searching the web and writing it up takes a while; the page shows it working meanwhile. */
+const RESEARCH_TIMEOUT_MS = 240_000
+
+async function researchFor(
+  ctx: ConnectorContext,
+  query: { code: string }
+): Promise<{ available: boolean; research: FaultCodeResearch[] }> {
+  const { status, body } = await call(ctx, `/v1/research/${encodeURIComponent(query.code)}`, {
+    timeoutMs: LOOKUP_TIMEOUT_MS,
+    accept: [400, 404],
+  })
+  // An older service has no research; a code it cannot read has none either.
+  if (status === 404 || status === 400) return { available: false, research: [] }
+  const parsed = z
+    .object({ available: z.boolean().catch(false), research: z.array(researchSchema).catch([]) })
+    .safeParse(body)
+  if (!parsed.success) invalid('research list')
+  return { available: parsed.data.available, research: parsed.data.research.map(researchOf) }
+}
+
+async function research(
+  ctx: ConnectorContext,
+  input: { code: string; meaning?: string; vehicle?: FaultCodeVehicle; workshopRef: string }
+): Promise<
+  { ok: true; research: FaultCodeResearch } | { ok: false; reason: FaultCodeResearchRefusal }
+> {
+  const { status, body } = await call(ctx, '/v1/research', {
+    method: 'POST',
+    body: {
+      code: input.code,
+      meaning: input.meaning,
+      vehicle: vehicleBody(input.vehicle),
+      workshop_ref: input.workshopRef,
+    },
+    timeoutMs: RESEARCH_TIMEOUT_MS,
+    accept: [404, 409, 429, 502],
+  })
+  if (status === 409) return { ok: false, reason: 'busy' }
+  if (status === 429) return { ok: false, reason: 'limited' }
+  if (status === 404 || status === 502) return { ok: false, reason: 'unavailable' }
+  const parsed = z.object({ research: researchSchema }).safeParse(body)
+  if (!parsed.success) invalid('research')
+  return { ok: true, research: researchOf(parsed.data.research) }
+}
+
+async function rateResearch(
+  ctx: ConnectorContext,
+  input: { id: string; helpful: boolean; workshopRef: string }
+): Promise<boolean> {
+  const { status } = await call(ctx, `/v1/research/${encodeURIComponent(input.id)}/feedback`, {
+    method: 'POST',
+    body: { helpful: input.helpful, workshop_ref: input.workshopRef },
+    timeoutMs: LOOKUP_TIMEOUT_MS,
+    accept: [404],
+  })
+  return status === 204
+}
+
+/** Two thousand rows with a few paragraphs each take a moment to compare. */
+const IMPORT_TIMEOUT_MS = 60_000
+
+const importCountsSchema = z.object({
+  new: z.number().int(),
+  change: z.number().int(),
+  same: z.number().int(),
+  problem: z.number().int(),
+})
+
+const importCheckSchema = z.object({
+  counts: importCountsSchema,
+  rows: z.array(
+    z.object({
+      n: z.number().int(),
+      code: z.string(),
+      state: z.enum(['new', 'change', 'same', 'problem']),
+      problem: z
+        .enum(['code', 'duplicate', 'title', 'empty', 'too_long', 'likelihood', 'meanings'])
+        .nullable()
+        .optional()
+        .catch('code'),
+      fields: z.array(z.string()).catch([]),
+      added_causes: z.number().int().catch(0),
+    })
+  ),
+})
+
+const importStatusSchema = z.object({
+  imports: z.array(
+    z.object({
+      id: z.string(),
+      make: z.string(),
+      lang: z.string(),
+      source: z.string().nullable().optional(),
+      file_name: z.string().nullable().optional(),
+      created_at: z.string(),
+      counts: z.object({
+        new: z.number().int(),
+        change: z.number().int(),
+        pending: z.number().int(),
+        published: z.number().int(),
+        rejected: z.number().int(),
+      }),
+      reviewed_at: z.string().nullable().optional(),
+    })
+  ),
+})
+
+function importBody(
+  input: Omit<CodeImportInput, 'workshopRef' | 'source' | 'fileName'> & Partial<CodeImportInput>
+) {
+  return {
+    make: input.make,
+    lang: input.lang,
+    ...(input.source ? { source: input.source } : {}),
+    ...(input.fileName ? { file_name: input.fileName } : {}),
+    ...(input.workshopRef ? { workshop_ref: input.workshopRef } : {}),
+    codes: input.rows.map((r) => ({
+      code: r.code,
+      ...(r.title ? { title: r.title } : {}),
+      ...(r.description ? { description: r.description } : {}),
+      ...(r.commonIssues ? { common_issues: r.commonIssues } : {}),
+      ...(r.resolution ? { resolution: r.resolution } : {}),
+      ...(r.advisory ? { advisory: r.advisory } : {}),
+      causes: r.causes.map((c) =>
+        c.likelihood ? { label: c.label, likelihood: c.likelihood } : { label: c.label }
+      ),
+    })),
+  }
+}
+
+function importRefusal(status: number, body: unknown): CodeImportRefusal | null {
+  if (status === 503) return 'paused'
+  if (status === 413) return 'too_large'
+  if (status === 400) {
+    const detail = (body as { detail?: unknown } | null)?.detail
+    return typeof detail === 'string' && detail.startsWith('nothing') ? 'nothing' : 'invalid'
+  }
+  return null
+}
+
+async function checkCodeImport(
+  ctx: ConnectorContext,
+  input: Omit<CodeImportInput, 'workshopRef' | 'source' | 'fileName'>
+): Promise<
+  | { ok: true; counts: CodeImportCounts; rows: CodeImportChecked[] }
+  | { ok: false; reason: CodeImportRefusal }
+> {
+  const { status, body } = await call(ctx, '/v1/code-imports/check', {
+    method: 'POST',
+    body: importBody(input),
+    timeoutMs: IMPORT_TIMEOUT_MS,
+    accept: [400, 413],
+  })
+  const refused = importRefusal(status, body)
+  if (refused) return { ok: false, reason: refused }
+  const parsed = importCheckSchema.safeParse(body)
+  if (!parsed.success) invalid('import check')
+  return {
+    ok: true,
+    counts: parsed.data.counts,
+    rows: parsed.data.rows.map((r) => ({
+      n: r.n,
+      code: r.code,
+      state: r.state,
+      problem: r.state === 'problem' ? (r.problem ?? 'code') : null,
+      fields: r.fields,
+      addedCauses: r.added_causes,
+    })),
+  }
+}
+
+async function sendCodeImport(
+  ctx: ConnectorContext,
+  input: CodeImportInput
+): Promise<
+  { ok: true; id: string; counts: CodeImportCounts } | { ok: false; reason: CodeImportRefusal }
+> {
+  const { status, body } = await call(ctx, '/v1/code-imports', {
+    method: 'POST',
+    body: importBody(input),
+    timeoutMs: IMPORT_TIMEOUT_MS,
+    accept: [400, 413, 503],
+  })
+  const refused = importRefusal(status, body)
+  if (refused) return { ok: false, reason: refused }
+  const parsed = z.object({ id: z.string(), counts: importCountsSchema }).safeParse(body)
+  if (!parsed.success) invalid('import')
+  return { ok: true, id: parsed.data.id, counts: parsed.data.counts }
+}
+
+async function codeImportStatus(ctx: ConnectorContext, ids: string[]): Promise<CodeImportStatus[]> {
+  if (ids.length === 0) return []
+  const { body } = await call(
+    ctx,
+    `/v1/code-imports?ids=${ids.slice(0, 100).map(encodeURIComponent).join(',')}`,
+    { timeoutMs: LOOKUP_TIMEOUT_MS }
+  )
+  const parsed = importStatusSchema.safeParse(body)
+  if (!parsed.success) invalid('import status')
+  return parsed.data.imports.map((i) => ({
+    id: i.id,
+    make: i.make,
+    lang: i.lang,
+    source: i.source ?? null,
+    fileName: i.file_name ?? null,
+    createdAt: i.created_at,
+    counts: i.counts,
+    reviewedAt: i.reviewed_at ?? null,
+  }))
+}
+
+const oemMakesSchema = z.object({ makes: z.array(z.string()).catch([]) })
+
+/** The makes the service serves own codes for; none from an older service that has no list. */
+async function oemMakes(ctx: ConnectorContext): Promise<string[]> {
+  const { status, body } = await call(ctx, '/v1/oem', {
+    timeoutMs: LOOKUP_TIMEOUT_MS,
+    accept: [404],
+  })
+  if (status === 404) return []
+  const parsed = oemMakesSchema.safeParse(body)
+  if (!parsed.success) invalid('make list')
+  return parsed.data.makes.map((m) => m.toLowerCase())
+}
+
+const oemFindSchema = z.object({
+  hits: z
+    .array(
+      z.object({
+        make: z.string(),
+        code: z.string(),
+        meanings: z.number().int(),
+        title: z.string().nullable().optional(),
+      })
+    )
+    .catch([]),
+})
+
+/** Every make that has a code; none from an older service that cannot search across makes. */
+async function findOemCode(
+  ctx: ConnectorContext,
+  query: { code: string; locale?: string }
+): Promise<OemFindHit[]> {
+  const params = new URLSearchParams()
+  if (query.locale) params.set('lang', query.locale)
+  const { status, body } = await call(
+    ctx,
+    `/v1/oem/codes/${encodeURIComponent(query.code)}?${params}`,
+    { timeoutMs: LOOKUP_TIMEOUT_MS, accept: [400, 404] }
+  )
+  if (status !== 200) return []
+  const parsed = oemFindSchema.safeParse(body)
+  if (!parsed.success) invalid('manufacturer code search')
+  return parsed.data.hits.map((h) => ({
+    make: h.make.toLowerCase(),
+    code: h.code,
+    meanings: h.meanings,
+    title: h.title?.trim() || null,
+  }))
+}
+
+async function oemCode(
+  ctx: ConnectorContext,
+  query: { source: string; code: string; chassis?: string; engine?: string; locale?: string }
+): Promise<OemCodeAnswer | null> {
+  const params = new URLSearchParams()
+  if (query.chassis) params.set('chassis', query.chassis)
+  if (query.engine) params.set('engine', query.engine)
+  if (query.locale) params.set('lang', query.locale)
+  const { status, body } = await call(
+    ctx,
+    `/v1/oem/${encodeURIComponent(query.source)}/codes/${encodeURIComponent(query.code)}?${params}`,
+    { timeoutMs: LOOKUP_TIMEOUT_MS, accept: [400, 404] }
+  )
+  // 404: the service does not serve this make's codes; 400: not such a code.
+  if (status === 404 || status === 400) return null
+  const parsed = oemReplySchema.safeParse(body)
+  if (!parsed.success) invalid('manufacturer code')
+  return {
+    source: parsed.data.make,
+    code: parsed.data.code,
+    meanings: parsed.data.meanings.map((m) => ({
+      // An older service gives no id; the text then names the meaning on the page.
+      id: m.id || m.text,
+      text: m.text,
+      textLang: m.text_lang,
+      textEn: m.text_en ?? null,
+      modules: m.modules.map((u) => ({
+        ref: u.ref,
+        family: u.family ?? null,
+        name: u.name ?? null,
+      })),
+      vehicles: m.vehicles.map((v) => ({
+        chassis: v.chassis,
+        model: v.model ?? null,
+        body: v.body ?? null,
+        yearFrom: v.year_from ?? null,
+        yearTo: v.year_to ?? null,
+      })),
+      engines: m.engines,
+      matches: m.matches,
+      ...ownText(m),
+    })),
+  }
+}
+
+/** The service's own text on a meaning; without a title there is none to show. */
+function ownText(m: z.infer<typeof oemReplySchema>['meanings'][number]) {
+  const title = m.title?.trim() || null
+  if (!title) return { lang: null, title: null, description: null, sections: [], causes: [] }
+  return {
+    lang: m.lang || 'en',
+    title,
+    description: m.description?.trim() || null,
+    sections: m.sections.filter((s) => s.text.trim()),
+    causes: m.causes.filter((c) => c.label.trim()),
+  }
+}
+
 async function searchCodes(
 async function searchCodes(
   ctx: ConnectorContext,
   ctx: ConnectorContext,
   query: { query: string; locale: string; limit?: number }
   query: { query: string; locale: string; limit?: number }
@@ -472,6 +1126,7 @@ async function shareFix(
       [
       [
         JSON.stringify({
         JSON.stringify({
           code: fix.code,
           code: fix.code,
+          meaning: fix.meaning,
           text: fix.text,
           text: fix.text,
           lang: fix.lang,
           lang: fix.lang,
           vehicle: vehicleBody(fix.vehicle),
           vehicle: vehicleBody(fix.vehicle),
@@ -590,6 +1245,20 @@ export const connector: ConnectorServer = {
 
 
   lookupCodes,
   lookupCodes,
   codeDetail,
   codeDetail,
+  researchFor,
+  research,
+  rateResearch,
+  shareWaveform,
+  publishedWaveforms,
+  waveformStatus,
+  withdrawWaveform,
+  waveformFile,
+  oemMakes,
+  oemCode,
+  findOemCode,
+  checkCodeImport,
+  sendCodeImport,
+  codeImportStatus,
   searchCodes,
   searchCodes,
   reportDefinition,
   reportDefinition,
   reportStatus,
   reportStatus,

+ 41 - 0
src/lib/analytics.ts

@@ -52,6 +52,47 @@ export type AnalyticsEvent =
   | 'plan:gate_show'
   | 'plan:gate_show'
   /** The upgrade dialog's button to the plans. */
   /** The upgrade dialog's button to the plans. */
   | 'plan:upgrade_click'
   | 'plan:upgrade_click'
+  /**
+   * A fault code opened, on its page or in the sheet beside a job: `kind` obd or make,
+   * `make` for a make's own code, and `found`, whether we had a meaning for it. The misses
+   * say which codes and makes to add next.
+   */
+  | 'fault_code:page_view'
+  /** The lookup box submitted: `kind` code, make_code or words. The words are never sent. */
+  | 'fault_code:search'
+  /** A make's own meaning opened from a standard code's page (Ford's P1000 from P1000). */
+  | 'fault_code:make_code_open'
+  /** Support asked about the code on screen. */
+  | 'fault_code:support_open'
+  /** A wrong meaning reported, with its `reason`. */
+  | 'fault_code:report_send'
+  /** A fix shared for review, with how many photos and whether any came from a phone. */
+  | 'fault_code:fix_share'
+  /** Research a fix pressed. */
+  | 'fault_code:research_start'
+  /** What research came back with: `outcome` pending, published, missed, busy, limited or failed. */
+  | 'fault_code:research_result'
+  /** A researched fix rated, `helpful` or not. */
+  | 'fault_code:research_rate'
+  /** A shared waveform opened full size, `kind` good or faulty. */
+  | 'fault_code:waveform_open'
+  /** Another waveform drawn under the open one to compare. */
+  | 'fault_code:waveform_compare'
+  /** A waveform's file downloaded: `role` psdata, csv or pssettings. */
+  | 'fault_code:waveform_download'
+  /** A waveform shared for review, with which files it carried. */
+  | 'fault_code:waveform_share'
+  /** Help us improve opened, and sent with its `topic`. */
+  | 'fault_code:improve_open'
+  | 'fault_code:improve_send'
+  /** The import dialog opened. */
+  | 'fault_code:import_open'
+  /** An example file downloaded: `format` xlsx, csv or json. */
+  | 'fault_code:import_example_download'
+  /** A file checked: its `make`, `file_type` and how many rows were new, changes, the same or problems. */
+  | 'fault_code:import_check'
+  /** An import sent for review: its `make` and how many `codes`. */
+  | 'fault_code:import_send'
 
 
 type Properties = Record<string, string | number | boolean | null>
 type Properties = Record<string, string | number | boolean | null>
 
 

+ 20 - 4
src/lib/photo-handoff.ts

@@ -218,6 +218,11 @@ export interface FixHandoff {
   userId: string
   userId: string
   /** Unix seconds. */
   /** Unix seconds. */
   expiresAt: number
   expiresAt: number
+  /**
+   * What the photos are for: a fix (up to four photos of what fixed it) or a waveform (one
+   * photo of the scope screen). A code made before waveforms is for a fix.
+   */
+  purpose: 'fix' | 'waveform'
 }
 }
 
 
 export function isFixHandoffToken(token: string): boolean {
 export function isFixHandoffToken(token: string): boolean {
@@ -225,7 +230,7 @@ export function isFixHandoffToken(token: string): boolean {
 }
 }
 
 
 export function createFixHandoffToken(
 export function createFixHandoffToken(
-  handoff: Omit<FixHandoff, 'expiresAt'>,
+  handoff: Omit<FixHandoff, 'expiresAt' | 'purpose'> & { purpose?: FixHandoff['purpose'] },
   now = Date.now()
   now = Date.now()
 ): { token: string; expiresAt: Date } {
 ): { token: string; expiresAt: Date } {
   const expiresAt = Math.floor(now / 1000) + PHOTO_HANDOFF_TTL_SECONDS
   const expiresAt = Math.floor(now / 1000) + PHOTO_HANDOFF_TTL_SECONDS
@@ -236,6 +241,7 @@ export function createFixHandoffToken(
       d: handoff.draftId,
       d: handoff.draftId,
       u: handoff.userId,
       u: handoff.userId,
       e: expiresAt,
       e: expiresAt,
+      ...(handoff.purpose === 'waveform' ? { p: 'w' } : {}),
     })
     })
   ).toString('base64url')
   ).toString('base64url')
   return {
   return {
@@ -251,13 +257,13 @@ export type FixHandoffCheck =
 export function verifyFixHandoffToken(token: string, now = Date.now()): FixHandoffCheck {
 export function verifyFixHandoffToken(token: string, now = Date.now()): FixHandoffCheck {
   const body = signedBody(token, FIX_PREFIX)
   const body = signedBody(token, FIX_PREFIX)
   if (!body) return { ok: false, reason: 'invalid' }
   if (!body) return { ok: false, reason: 'invalid' }
-  let payload: { o?: unknown; c?: unknown; d?: unknown; u?: unknown; e?: unknown }
+  let payload: { o?: unknown; c?: unknown; d?: unknown; u?: unknown; e?: unknown; p?: unknown }
   try {
   try {
     payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
     payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
   } catch {
   } catch {
     return { ok: false, reason: 'invalid' }
     return { ok: false, reason: 'invalid' }
   }
   }
-  const { o, c, d, u, e } = payload
+  const { o, c, d, u, e, p } = payload
   if (
   if (
     typeof o !== 'string' ||
     typeof o !== 'string' ||
     typeof c !== 'string' ||
     typeof c !== 'string' ||
@@ -268,5 +274,15 @@ export function verifyFixHandoffToken(token: string, now = Date.now()): FixHando
     return { ok: false, reason: 'invalid' }
     return { ok: false, reason: 'invalid' }
   }
   }
   if (e * 1000 <= now) return { ok: false, reason: 'expired' }
   if (e * 1000 <= now) return { ok: false, reason: 'expired' }
-  return { ok: true, handoff: { organizationId: o, code: c, draftId: d, userId: u, expiresAt: e } }
+  return {
+    ok: true,
+    handoff: {
+      organizationId: o,
+      code: c,
+      draftId: d,
+      userId: u,
+      expiresAt: e,
+      purpose: p === 'w' ? 'waveform' : 'fix',
+    },
+  }
 }
 }