Просмотр исходного кода

Add Open Labor Project: a labor guide on work orders and quotes, with fluids as parts and stored lookups (#496)

* first test

* Store Open Labor Project lookups while the key is paid, add settings, fluids as parts and a rebuilt labor guide

- Stored answers: a new repair_data_cache table keeps a model year's
  answer while the workshop's key is on a paid plan, and deletes it when a
  reply or the daily check shows the free tier, the key is refused, or the
  workshop disconnects. Refreshed after 30 days; VINs kept as a digest.
- Shared key later: OPEN_LABOR_PROJECT_API_KEY makes the workshop's key
  optional and shares lookups between workshops, with a daily allowance
  per workshop. Unset today.
- Settings: minimum confidence, book time or top of the range, adjust %,
  rounding, fluids on or off, fluid units.
- Lookups: model names are matched to Open Labor Project's own slugs
  ("3-Serie (E9*)" is 3-series), the VIN is only tried when the model is
  unknown, the name found is reused across tabs, empty answers are
  remembered for six hours, and a VIN it cannot place reads as not found.
- Fluids as parts: matched against stock by grade or spec, else a plain
  line with the amount.
- The dialog is rebuilt: category rail, search, grouped rows, fluid
  cards, engine warnings, and a still-looking note for slow answers.
- The connection test asks for one real job instead of a call with no
  vehicle.

Migration 20261103120000_repair_data_cache adds one table; nothing
existing changes.

* Hide unlocked labor times below the minimum confidence, and keep G12 coolants apart

- A free key's locked rows stay listed because they have no confidence to
  judge yet. Once Show hours reveals one below the workshop's minimum, the
  unlock now answers "hidden": the dialog drops the row and counts it with
  the others the setting hides, so it can no longer be added.
- Stock matching read G12, G12+ and G12++ as the same coolant. The whole
  suffix is now kept, with "plus" and "+" written one way, so a G12++
  requirement is never offered plain G12.

Both from the Copilot review.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
66ab377725
67 измененных файлов с 7698 добавлено и 6 удалено
  1. 7 0
      .env.example
  2. 43 0
      messages/de/integrations.json
  3. 78 0
      messages/de/service.json
  4. 43 0
      messages/en/integrations.json
  5. 78 0
      messages/en/service.json
  6. 43 0
      messages/es/integrations.json
  7. 78 0
      messages/es/service.json
  8. 43 0
      messages/fr/integrations.json
  9. 78 0
      messages/fr/service.json
  10. 43 0
      messages/it/integrations.json
  11. 78 0
      messages/it/service.json
  12. 43 0
      messages/lt/integrations.json
  13. 78 0
      messages/lt/service.json
  14. 43 0
      messages/nb/integrations.json
  15. 78 0
      messages/nb/service.json
  16. 43 0
      messages/nl/integrations.json
  17. 78 0
      messages/nl/service.json
  18. 43 0
      messages/pl/integrations.json
  19. 78 0
      messages/pl/service.json
  20. 43 0
      messages/pt-BR/integrations.json
  21. 78 0
      messages/pt-BR/service.json
  22. 43 0
      messages/pt-PT/integrations.json
  23. 78 0
      messages/pt-PT/service.json
  24. 43 0
      messages/ru/integrations.json
  25. 78 0
      messages/ru/service.json
  26. 43 0
      messages/tr/integrations.json
  27. 78 0
      messages/tr/service.json
  28. 31 0
      prisma/migrations/20261103120000_repair_data_cache/migration.sql
  29. 33 0
      prisma/schema/integrations.prisma
  30. 21 0
      public/images/integrations/openlaborproject.svg
  31. 251 0
      src/__tests__/features/integrations/fixtures/openlaborproject/fluids-model.json
  32. 39 0
      src/__tests__/features/integrations/fixtures/openlaborproject/fluids-one-system.json
  33. 194 0
      src/__tests__/features/integrations/fixtures/openlaborproject/labor-model.json
  34. 41 0
      src/__tests__/features/integrations/fixtures/openlaborproject/labor-one-job.json
  35. 11 0
      src/__tests__/features/integrations/fixtures/openlaborproject/not-found.json
  36. 213 0
      src/__tests__/features/integrations/fluid-stock.test.ts
  37. 564 0
      src/__tests__/features/integrations/openlaborproject.test.ts
  38. 639 0
      src/__tests__/features/integrations/repair-data.test.ts
  39. 121 0
      src/__tests__/features/integrations/repair-prefs.test.ts
  40. 97 0
      src/__tests__/features/vehicles/fluid-part-button.test.tsx
  41. 134 0
      src/__tests__/features/vehicles/labor-times-panel.test.tsx
  42. 1 0
      src/__tests__/lib/files/references.test.ts
  43. 10 2
      src/__tests__/setup.ts
  44. 1 0
      src/app/(authenticated)/settings/integrations/integrations-catalog.tsx
  45. 244 0
      src/features/integrations/Actions/repairDataActions.ts
  46. 264 0
      src/features/integrations/Lib/fluid-stock.ts
  47. 7 0
      src/features/integrations/Lib/repair-data-contract.ts
  48. 598 0
      src/features/integrations/Lib/repair-data.ts
  49. 108 0
      src/features/integrations/Lib/repair-prefs.ts
  50. 130 0
      src/features/integrations/Lib/types.ts
  51. 24 0
      src/features/quotes/Components/QuoteLaborEditor.tsx
  52. 34 1
      src/features/quotes/Components/QuotePageClient.tsx
  53. 169 0
      src/features/vehicles/Components/labor-guide/FluidPartButton.tsx
  54. 218 0
      src/features/vehicles/Components/labor-guide/FluidSpecsPanel.tsx
  55. 84 0
      src/features/vehicles/Components/labor-guide/LaborGuideButton.tsx
  56. 511 0
      src/features/vehicles/Components/labor-guide/LaborGuideDialog.tsx
  57. 326 0
      src/features/vehicles/Components/labor-guide/LaborTimesPanel.tsx
  58. 7 0
      src/features/vehicles/Components/labor-guide/guide-lines.ts
  59. 197 0
      src/features/vehicles/Components/labor-guide/guide-parts.tsx
  60. 44 0
      src/features/vehicles/Components/service-edit/LaborEditor.tsx
  61. 17 1
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  62. 18 1
      src/features/vehicles/Components/service-page/modern/ModernDetails.tsx
  63. 110 0
      src/integrations/openlaborproject/manifest.ts
  64. 567 0
      src/integrations/openlaborproject/server.ts
  65. 27 1
      src/integrations/registry.ts
  66. 2 0
      src/lib/backup/manifest.ts
  67. 11 0
      src/lib/backup/tenancy-schema.ts

+ 7 - 0
.env.example

@@ -66,3 +66,10 @@ QUICKBOOKS_INTEGRATION_CLIENT_SECRET=""
 # development only the Fiken users listed on the app can connect.
 FIKEN_INTEGRATION_CLIENT_ID=""
 FIKEN_INTEGRATION_CLIENT_SECRET=""
+# One Open Labor Project key for every workshop on this install. Unset, each
+# workshop pastes its own key when it connects the labor guide. Set, a
+# workshop that leaves its key empty uses this one, lookups made with it are
+# shared between workshops, and each workshop gets the daily number of
+# lookups below (40 unless set).
+# OPEN_LABOR_PROJECT_API_KEY=""
+# REPAIR_DATA_SERVER_KEY_DAILY_LOOKUPS=40

+ 43 - 0
messages/de/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatisierung",
     "storage": "Dateien",
     "registry": "Fahrzeugregister",
+    "repair": "Reparaturdaten",
     "other": "Sonstiges",
     "ai": "KI"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Sicherungsziel"
+    },
+    "repair": {
+      "labor": "Arbeitszeiten in Aufträgen und Angeboten",
+      "fluids": "Flüssigkeiten und Füllmengen"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Einmal täglich werden Modelle, die in der letzten Woche nicht aktualisiert wurden, erneut abgerufen, damit die Fahrzeugseite nie wartet und ein neuer Rückruf innerhalb einer Woche nach Einreichung erscheint. Nur Marke, Modell und Jahr werden gesendet."
       }
     },
+    "openlaborproject": {
+      "description": "Schlagen Sie Arbeitswerte und Füllmengen für das Fahrzeug eines Auftrags oder Angebots nach und übernehmen Sie die Stunden einer Arbeit mit einem Klick als Arbeitsposition. Die meisten Daten gelten für Fahrzeuge des US-Markts, und der Katalog zeigt, für welchen Markt eine Abfrage gilt. Jeder Wert zeigt, wie sicher Open Labor Project ist, und viele sind KI-Schätzungen. Prüfen Sie sie daher, bevor Sie einem Kunden ein Angebot machen. Abfragen nutzen den eigenen API-Schlüssel der Werkstatt. Mit einem kostenpflichtigen Tarif von Open Labor Project bleibt ein einmal abgefragtes Fahrzeug gespeichert, solange der Tarif läuft, sodass eine erneute Abfrage keinen Abruf kostet.",
+      "fields": {
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Beantragen Sie einen Schlüssel auf openlaborproject.com unter Developers; er kommt per E-Mail. Ein kostenloser Schlüssel hat 10 Abrufe pro Tag und zeigt die Stunden jeweils einer Arbeit. Ab dem Builder-Tarif steigt das Limit, und die Stunden aller Arbeiten werden auf einmal angezeigt.",
+        "apiKeyServerHelp": "Lassen Sie das Feld leer, um den Schlüssel dieses Servers zu nutzen, oder fügen Sie Ihren eigenen Schlüssel von openlaborproject.com ein, um Ihren eigenen Tarif zu verwenden."
+      },
+      "settings": {
+        "minConfidence": "Arbeitszeiten anzeigen ab",
+        "minConfidenceHelp": "Lässt Zeiten weg, bei denen die Quelle weniger sicher ist. Viele Zeiten für europäische Marken sind KI-Schätzungen, eine strenge Wahl kann also wenige Arbeiten übrig lassen.",
+        "confidenceAll": "Jede Sicherheitsstufe",
+        "confidenceMedium": "Mittel und höher",
+        "confidenceHigh": "Geprüft und höher",
+        "confidenceOem": "Nur OEM",
+        "hoursFrom": "Zu übernehmende Stunden",
+        "hoursFromHelp": "Welcher Wert in den Auftrag geht: die Richtzeit oder das obere Ende der Spanne von Open Labor Project (zum Beispiel 1,6 Std. statt 1,2 Std.). Das obere Ende lässt Luft für Arbeiten, die länger dauern.",
+        "hoursBook": "Richtzeit",
+        "hoursHigh": "Oberes Ende der Spanne",
+        "adjustPercent": "Zeiten anpassen um (%)",
+        "adjustPercentHelp": "Ein Prozentsatz, der auf jede Zeit aufgeschlagen wird, bevor sie in den Auftrag geht: 10 macht aus 1,2 Std. 1,32 Std., -10 macht daraus 1,08 Std. Von -50 bis 200.",
+        "rounding": "Stunden aufrunden auf",
+        "roundingHelp": "Rundet die Stunden jeder Arbeit nach einer etwaigen Anpassung auf: mit 0,25 werden aus 1,32 Std. 1,5 Std. Eine Zeit, die schon auf einem Schritt liegt, bleibt, wie sie ist.",
+        "roundNone": "Nicht runden",
+        "roundTenth": "0,1 Stunde",
+        "roundQuarter": "0,25 Stunde",
+        "roundHalf": "0,5 Stunde",
+        "fluids": "Flüssigkeitsangaben anzeigen",
+        "fluidsHelp": "Fügt einen Reiter Flüssigkeiten mit Füllmengen, Spezifikationen und Wechselintervallen hinzu. Das Öffnen kostet einen eigenen Abruf.",
+        "units": "Einheiten für Flüssigkeiten",
+        "unitsHelp": "In welchen Einheiten Füllmengen und Wechselintervalle angezeigt werden.",
+        "unitsBoth": "Beide",
+        "unitsMetric": "Metrisch (L, km)",
+        "unitsUs": "US (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Sprache zu Text",
       "description": "Diktat auf einem Arbeitsauftrag, aufgeschrieben von einem Sprachmodell statt vom Browser: viel genauer, in jeder Sprache, und es kennt die Wörter einer Werkstatt. Getrennt von Ihrem KI-Anbieter, den Sie also so lassen können, wie er ist. Nutzt Ihren eigenen OpenAI-Schlüssel oder einen eigenen Server, der die Transkriptions-API von OpenAI spricht. Die Aufnahme wird nur zum Aufschreiben gesendet und nicht aufbewahrt.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Aktualisierung der Rückrufdaten",
       "vehicle": "Rückrufprüfung für ein neues Fahrzeug"
+    },
+    "repair": {
+      "checkPlan": "Prüfen, ob gespeicherte Abfragen bleiben dürfen"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/de/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Die Werkstattpauschale aus den Einstellungen: {percent} % der Arbeit dieses Auftrags. Löschen Sie die Position, um sie wegzulassen.",
     "shopFeeHintPercentAll": "Die Werkstattpauschale aus den Einstellungen: {percent} % von Arbeit und Teilen dieses Auftrags. Löschen Sie die Position, um sie wegzulassen."
   },
+  "laborGuide": {
+    "button": "Arbeitswerte",
+    "title": "Arbeitswerte",
+    "description": "Arbeitszeiten und Flüssigkeiten für dieses Fahrzeug",
+    "tabLabor": "Arbeitszeiten",
+    "tabFluids": "Flüssigkeiten",
+    "engine": "Motor",
+    "categories": "Arbeitsbereiche",
+    "allJobs": "Alle Arbeiten",
+    "search": "Arbeiten suchen, z. B. Bremsbeläge",
+    "clearSearch": "Suche löschen",
+    "jobCount": "{count, plural, one {# Arbeit} other {# Arbeiten}}",
+    "noMatch": "Keine Arbeit passt zu „{query}“",
+    "noJobs": "Der Katalog listet für diesen Motor keine Arbeiten.",
+    "showAll": "Alle Arbeiten anzeigen",
+    "showHours": "Stunden anzeigen",
+    "show": "Anzeigen",
+    "lockedValue": "Mit diesem Schlüssel nicht sichtbar",
+    "hours": "{hours} Std.",
+    "range": "{low} bis {high} Std.",
+    "bookTime": "Richtzeit {hours} Std.",
+    "add": "Als Arbeitsposition hinzufügen",
+    "added": "Hinzugefügt",
+    "addJob": "{job} als Arbeitsposition hinzufügen",
+    "addedJob": "{job} ist hinzugefügt",
+    "addedCount": "{count, plural, one {# Arbeit hinzugefügt} other {# Arbeiten hinzugefügt}}",
+    "partsAddedCount": "{count, plural, one {# Teil hinzugefügt} other {# Teile hinzugefügt}}",
+    "done": "Fertig",
+    "loading": "Fahrzeug wird nachgeschlagen",
+    "stillLooking": "Die Suche läuft noch. Bei einem Fahrzeug mit wenigen Daten kann Open Labor Project bis zu einer halben Minute brauchen.",
+    "notFoundTitle": "Nicht im Katalog",
+    "notFound": "Der Katalog hat nichts zu diesem Fahrzeug. Prüfen Sie, ob Marke, Modell und Baujahr so geschrieben sind, wie der Hersteller sie nennt, oder ergänzen Sie die FIN.",
+    "fluidsNotFoundTitle": "Keine Flüssigkeitsangaben für dieses Fahrzeug",
+    "fluidsNotFound": "Open Labor Project hat für dieses Fahrzeug noch keine Flüssigkeitsangaben.",
+    "failedTitle": "Die Abfrage ist nicht durchgegangen",
+    "failed": "Die Abfrage ist fehlgeschlagen",
+    "unlockFailed": "Der Wert konnte nicht abgerufen werden",
+    "retry": "Erneut versuchen",
+    "matchNone": "Keiner der aufgeführten Motoren passt zu dem, der für dieses Fahrzeug hinterlegt ist. Die Zeiten können daher für einen anderen Motor gelten. Wählen Sie oben den passendsten Motor.",
+    "matchFuel": "Der Motor wurde nach der Kraftstoffart gewählt. Prüfen Sie, ob er stimmt.",
+    "matchUnknown": "Für dieses Fahrzeug ist kein Hubraum hinterlegt. Prüfen Sie daher, ob der Motor oben stimmt.",
+    "hiddenByConfidence": "{count, plural, one {# Zeit unter Ihrer Mindestsicherheit ist ausgeblendet.} other {# Zeiten unter Ihrer Mindestsicherheit sind ausgeblendet.}}",
+    "changeSettings": "Ändern",
+    "liters": "{value} l",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Füllmenge",
+    "drainRefill": "Ablassen und Auffüllen",
+    "interval": "Wechsel alle",
+    "copySpec": "{spec} kopieren",
+    "copied": "Kopiert",
+    "noFluids": "Der Katalog listet für diesen Motor keine Flüssigkeiten.",
+    "addAsPart": "Als Teil hinzufügen",
+    "addFluidAsPart": "{system} als Teil hinzufügen",
+    "partAdded": "Hinzugefügt",
+    "fromStock": "Aus dem Lager",
+    "addWithoutStock": "Ohne Lagerartikel hinzufügen",
+    "takesDrainRefill": "Braucht {amount} beim Ablassen und Auffüllen",
+    "takesCapacity": "Fasst {amount} insgesamt",
+    "attribution": "Daten von {source}",
+    "market": "Markt: {region}",
+    "about": "Wie sicher ist das?",
+    "aboutTitle": "Wie sicher die Quelle ist",
+    "estimateNote": "Als Schätzung markierte Werte sind KI-generiert und nicht mit Herstellerangaben abgeglichen. Prüfen Sie sie, bevor Sie einem Kunden ein Angebot machen.",
+    "lockedNote": "Dieser Schlüssel zeigt Werte einzeln an, und jeder zählt als Abruf. Ein kostenpflichtiger Tarif zeigt alle auf einmal.",
+    "settings": "Einstellungen für Arbeitswerte",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Mit den Herstellerdaten abgeglichen",
+      "high": "Geprüft",
+      "highHelp": "Von der Quelle geprüft",
+      "medium": "Mittel",
+      "mediumHelp": "Die Quelle stuft diesen Wert als mittel sicher ein",
+      "estimated": "Schätzung",
+      "estimatedHelp": "KI-generiert und nicht mit Herstellerangaben abgeglichen"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Ein Techniker hat Arbeit zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Arbeitspositionen zu diesem Auftrag hinzugefügt}}",
     "body": "Sie bleibt beim Bearbeiten aus der Liste. Beim nächsten Speichern wird sie ohnehin übernommen.",

+ 43 - 0
messages/en/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automation",
     "storage": "Files",
     "registry": "Vehicle registry",
+    "repair": "Repair data",
     "other": "Other",
     "ai": "AI"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Backup destination"
+    },
+    "repair": {
+      "labor": "Labor times on work orders and quotes",
+      "fluids": "Fluid types and capacities"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Once a day, models not refreshed in the last week are fetched again, so the vehicle page never waits and a new recall shows within a week of being filed. Only make, model and year are sent."
       }
     },
+    "openlaborproject": {
+      "description": "Look up book labor times and fluid capacities for the vehicle on a work order or quote, and add a job's hours as a labor line in one click. Most of the data is for US-market vehicles, and the guide says which market each lookup is for. Each value shows how sure Open Labor Project is of it, and many are AI-derived estimates, so check them before you quote a customer. Lookups use the workshop's own API key. On a paid Open Labor Project plan, a vehicle looked up once is kept for as long as the plan lasts, so looking it up again costs no call.",
+      "fields": {
+        "apiKey": "API key",
+        "apiKeyHelp": "Request a key on openlaborproject.com under Developers; it arrives by email. A free key has 10 calls a day and shows hours one job at a time. The Builder plan and up raise the limit and show every job's hours at once.",
+        "apiKeyServerHelp": "Leave this empty to use the key this server provides, or paste your own key from openlaborproject.com to use your own plan."
+      },
+      "settings": {
+        "minConfidence": "Show labor times from",
+        "minConfidenceHelp": "Leave out times the source is less sure of. Many times for European makes are AI estimates, so a strict choice can leave few jobs.",
+        "confidenceAll": "Every confidence level",
+        "confidenceMedium": "Medium and up",
+        "confidenceHigh": "Verified and up",
+        "confidenceOem": "OEM only",
+        "hoursFrom": "Hours to add",
+        "hoursFromHelp": "Which figure goes on the work order: the book time, or the top of the range Open Labor Project gives (1.6 h instead of 1.2 h, say). The top of the range leaves room for jobs that run long.",
+        "hoursBook": "Book time",
+        "hoursHigh": "Top of the range",
+        "adjustPercent": "Adjust times by (%)",
+        "adjustPercentHelp": "A percentage added to every time before it goes on the work order: 10 turns 1.2 h into 1.32 h, -10 turns it into 1.08 h. From -50 to 200.",
+        "rounding": "Round hours up to",
+        "roundingHelp": "Rounds each job's hours up, after any adjustment: with 0.25, 1.32 h becomes 1.5 h. A time already on a step stays as it is.",
+        "roundNone": "Do not round",
+        "roundTenth": "0.1 hour",
+        "roundQuarter": "0.25 hour",
+        "roundHalf": "0.5 hour",
+        "fluids": "Show fluid specs",
+        "fluidsHelp": "Adds a Fluids tab with capacities, specs and change intervals. Opening it uses a call of its own.",
+        "units": "Fluid units",
+        "unitsHelp": "The units capacities and change intervals are shown in.",
+        "unitsBoth": "Both",
+        "unitsMetric": "Metric (L, km)",
+        "unitsUs": "US (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Speech to text",
       "description": "Dictation on a work order, written down by a speech model instead of the browser: far more accurate, any language, and it knows a workshop's words. Separate from your AI provider, so you can keep that one as it is. Uses your own OpenAI key, or a server of your own that speaks OpenAI's transcription API. The recording is sent to be written down and is not kept.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Recall data refresh",
       "vehicle": "Recall check for a new vehicle"
+    },
+    "repair": {
+      "checkPlan": "Check that stored lookups may be kept"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/en/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "The shop fee from Settings: {percent} % of the labor on this job. Delete the line to leave it off.",
     "shopFeeHintPercentAll": "The shop fee from Settings: {percent} % of the labor and parts on this job. Delete the line to leave it off."
   },
+  "laborGuide": {
+    "button": "Labor guide",
+    "title": "Labor guide",
+    "description": "Book times and fluids for this vehicle",
+    "tabLabor": "Labor times",
+    "tabFluids": "Fluids",
+    "engine": "Engine",
+    "categories": "Job categories",
+    "allJobs": "All jobs",
+    "search": "Search jobs, such as brake pads",
+    "clearSearch": "Clear search",
+    "jobCount": "{count, plural, one {# job} other {# jobs}}",
+    "noMatch": "No jobs match “{query}”",
+    "noJobs": "The guide lists no jobs for this engine.",
+    "showAll": "Show all jobs",
+    "showHours": "Show hours",
+    "show": "Show",
+    "lockedValue": "Not shown on this key",
+    "hours": "{hours} h",
+    "range": "{low} to {high} h",
+    "bookTime": "Book {hours} h",
+    "add": "Add as labor line",
+    "added": "Added",
+    "addJob": "Add {job} as a labor line",
+    "addedJob": "{job} is added",
+    "addedCount": "{count, plural, one {# job added} other {# jobs added}}",
+    "partsAddedCount": "{count, plural, one {# part added} other {# parts added}}",
+    "done": "Done",
+    "loading": "Looking up the vehicle",
+    "stillLooking": "Still looking. Open Labor Project can take up to half a minute for a vehicle it has little data on.",
+    "notFoundTitle": "Not in the guide",
+    "notFound": "The guide has nothing for this vehicle. Check that the make, model and year are written the way the maker names them, or add the VIN.",
+    "fluidsNotFoundTitle": "No fluid specs for this vehicle",
+    "fluidsNotFound": "Open Labor Project has no fluid specs for this vehicle yet.",
+    "failedTitle": "The lookup did not go through",
+    "failed": "The lookup failed",
+    "unlockFailed": "Could not get that value",
+    "retry": "Try again",
+    "matchNone": "None of the engines listed matches the one on file for this vehicle, so these times may be for a different engine. Choose the closest engine above.",
+    "matchFuel": "The engine was picked by fuel type. Check that it is the right one.",
+    "matchUnknown": "This vehicle has no engine size on file, so check that the engine above is the right one.",
+    "hiddenByConfidence": "{count, plural, one {# time below your minimum confidence is hidden.} other {# times below your minimum confidence are hidden.}}",
+    "changeSettings": "Change",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Capacity",
+    "drainRefill": "Drain and refill",
+    "interval": "Change every",
+    "copySpec": "Copy {spec}",
+    "copied": "Copied",
+    "noFluids": "The guide lists no fluids for this engine.",
+    "addAsPart": "Add as part",
+    "addFluidAsPart": "Add {system} as a part",
+    "partAdded": "Added",
+    "fromStock": "From stock",
+    "addWithoutStock": "Add without a stock item",
+    "takesDrainRefill": "Takes {amount} at a drain and refill",
+    "takesCapacity": "Takes {amount} in all",
+    "attribution": "Data from {source}",
+    "market": "{region} market",
+    "about": "How sure is this?",
+    "aboutTitle": "How sure the source is",
+    "estimateNote": "Values marked Estimate are AI-derived and not checked against a maker's guide. Check them before you quote a customer.",
+    "lockedNote": "This key shows values one at a time, and each one counts as a call. A paid plan shows them all at once.",
+    "settings": "Labor guide settings",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Matched against the maker's own data",
+      "high": "Verified",
+      "highHelp": "Verified by the source",
+      "medium": "Medium",
+      "mediumHelp": "The source rates this value as medium confidence",
+      "estimated": "Estimate",
+      "estimatedHelp": "AI-derived and not checked against a maker's guide"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {A technician added labor to this job} other {A technician added # labor lines to this job}}",
     "body": "It stays out of the list while you are editing. Your next save includes it either way.",

+ 43 - 0
messages/es/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatización",
     "storage": "Archivos",
     "registry": "Registro de vehículos",
+    "repair": "Datos de reparación",
     "other": "Otras",
     "ai": "IA"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Destino de copias de seguridad"
+    },
+    "repair": {
+      "labor": "Tiempos de mano de obra en órdenes de trabajo y presupuestos",
+      "fluids": "Tipos y capacidades de fluidos"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Una vez al día se vuelven a consultar los modelos no actualizados en la última semana, para que la página del vehículo nunca espere y una nueva llamada a revisión aparezca en una semana. Solo se envían marca, modelo y año."
       }
     },
+    "openlaborproject": {
+      "description": "Consulta los tiempos de mano de obra y las capacidades de fluidos del vehículo de una orden de trabajo o un presupuesto, y añade las horas de un trabajo como línea de mano de obra con un clic. La mayoría de los datos son de vehículos del mercado estadounidense, y la guía indica a qué mercado corresponde cada consulta. Cada valor muestra la confianza de Open Labor Project, y muchos son estimaciones generadas con IA, así que compruébalos antes de dar un presupuesto al cliente. Las consultas usan la clave de API del propio taller. Con un plan de pago de Open Labor Project, un vehículo consultado una vez se guarda mientras dure el plan, así que volver a consultarlo no gasta ninguna llamada.",
+      "fields": {
+        "apiKey": "Clave de API",
+        "apiKeyHelp": "Solicita una clave en openlaborproject.com, en Developers; llega por correo. Una clave gratuita tiene 10 consultas al día y muestra las horas de un trabajo cada vez. El plan Builder y superiores amplían el límite y muestran las horas de todos los trabajos a la vez.",
+        "apiKeyServerHelp": "Déjalo vacío para usar la clave que proporciona este servidor, o pega tu propia clave de openlaborproject.com para usar tu propio plan."
+      },
+      "settings": {
+        "minConfidence": "Mostrar tiempos desde",
+        "minConfidenceHelp": "Omite los tiempos de los que la fuente está menos segura. Muchos tiempos de marcas europeas son estimaciones con IA, así que una opción estricta puede dejar pocos trabajos.",
+        "confidenceAll": "Cualquier nivel de confianza",
+        "confidenceMedium": "Media o superior",
+        "confidenceHigh": "Verificado o superior",
+        "confidenceOem": "Solo OEM",
+        "hoursFrom": "Horas que se añaden",
+        "hoursFromHelp": "Qué cifra va a la orden de trabajo: el tiempo de guía, o el máximo del rango que da Open Labor Project (por ejemplo, 1,6 h en lugar de 1,2 h). El máximo del rango deja margen para los trabajos que se alargan.",
+        "hoursBook": "Tiempo de guía",
+        "hoursHigh": "Máximo del rango",
+        "adjustPercent": "Ajustar tiempos (%)",
+        "adjustPercentHelp": "Un porcentaje que se suma a cada tiempo antes de pasar a la orden de trabajo: 10 convierte 1,2 h en 1,32 h, y -10 en 1,08 h. De -50 a 200.",
+        "rounding": "Redondear horas hacia arriba a",
+        "roundingHelp": "Redondea hacia arriba las horas de cada trabajo, después de cualquier ajuste: con 0,25, 1,32 h pasa a 1,5 h. Un tiempo que ya está en un paso se queda igual.",
+        "roundNone": "No redondear",
+        "roundTenth": "0,1 hora",
+        "roundQuarter": "0,25 horas",
+        "roundHalf": "0,5 horas",
+        "fluids": "Mostrar especificaciones de fluidos",
+        "fluidsHelp": "Añade una pestaña Fluidos con capacidades, especificaciones e intervalos de cambio. Abrirla usa una llamada propia.",
+        "units": "Unidades de fluidos",
+        "unitsHelp": "Las unidades en que se muestran capacidades e intervalos de cambio.",
+        "unitsBoth": "Ambas",
+        "unitsMetric": "Métricas (L, km)",
+        "unitsUs": "EE. UU. (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Voz a texto",
       "description": "El dictado en una orden de trabajo lo transcribe un modelo de voz en lugar del navegador: mucho más preciso, en cualquier idioma y conoce las palabras de un taller. Es independiente de tu proveedor de IA, así que puedes dejar ese como está. Usa tu propia clave de OpenAI o un servidor propio que hable la API de transcripción de OpenAI. La grabación se envía para transcribirla y no se guarda.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Actualización de datos de llamadas a revisión",
       "vehicle": "Comprobación de llamadas a revisión para un vehículo nuevo"
+    },
+    "repair": {
+      "checkPlan": "Comprobar que las consultas guardadas pueden conservarse"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/es/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "El cargo de taller de Ajustes: {percent} % de la mano de obra de este trabajo. Borra la línea para quitarlo.",
     "shopFeeHintPercentAll": "El cargo de taller de Ajustes: {percent} % de la mano de obra y las piezas de este trabajo. Borra la línea para quitarlo."
   },
+  "laborGuide": {
+    "button": "Guía de tiempos",
+    "title": "Guía de tiempos",
+    "description": "Tiempos de mano de obra y fluidos de este vehículo",
+    "tabLabor": "Tiempos de mano de obra",
+    "tabFluids": "Fluidos",
+    "engine": "Motor",
+    "categories": "Categorías de trabajo",
+    "allJobs": "Todos los trabajos",
+    "search": "Buscar trabajos, como pastillas de freno",
+    "clearSearch": "Borrar búsqueda",
+    "jobCount": "{count, plural, one {# trabajo} other {# trabajos}}",
+    "noMatch": "Ningún trabajo coincide con «{query}»",
+    "noJobs": "La guía no tiene trabajos para este motor.",
+    "showAll": "Ver todos los trabajos",
+    "showHours": "Ver horas",
+    "show": "Ver",
+    "lockedValue": "No se muestra con esta clave",
+    "hours": "{hours} h",
+    "range": "{low} a {high} h",
+    "bookTime": "Tiempo de guía {hours} h",
+    "add": "Añadir como línea de mano de obra",
+    "added": "Añadido",
+    "addJob": "Añadir {job} como línea de mano de obra",
+    "addedJob": "{job} está añadido",
+    "addedCount": "{count, plural, one {# trabajo añadido} other {# trabajos añadidos}}",
+    "partsAddedCount": "{count, plural, one {# pieza añadida} other {# piezas añadidas}}",
+    "done": "Listo",
+    "loading": "Buscando el vehículo",
+    "stillLooking": "Seguimos buscando. Open Labor Project puede tardar hasta medio minuto con un vehículo del que tiene pocos datos.",
+    "notFoundTitle": "No está en la guía",
+    "notFound": "La guía no tiene datos de este vehículo. Comprueba que la marca, el modelo y el año estén escritos como los nombra el fabricante, o añade el VIN.",
+    "fluidsNotFoundTitle": "No hay especificaciones de fluidos para este vehículo",
+    "fluidsNotFound": "Open Labor Project aún no tiene especificaciones de fluidos para este vehículo.",
+    "failedTitle": "La consulta no se completó",
+    "failed": "La consulta falló",
+    "unlockFailed": "No se pudo obtener ese valor",
+    "retry": "Reintentar",
+    "matchNone": "Ninguno de los motores de la lista coincide con el registrado para este vehículo, así que estos tiempos pueden ser de otro motor. Elige arriba el motor más parecido.",
+    "matchFuel": "El motor se eligió por el tipo de combustible. Comprueba que sea el correcto.",
+    "matchUnknown": "Este vehículo no tiene cilindrada registrada, así que comprueba que el motor de arriba sea el correcto.",
+    "hiddenByConfidence": "{count, plural, one {Se oculta # tiempo por debajo de tu confianza mínima.} other {Se ocultan # tiempos por debajo de tu confianza mínima.}}",
+    "changeSettings": "Cambiar",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Capacidad",
+    "drainRefill": "Vaciado y llenado",
+    "interval": "Cambiar cada",
+    "copySpec": "Copiar {spec}",
+    "copied": "Copiado",
+    "noFluids": "La guía no tiene fluidos para este motor.",
+    "addAsPart": "Añadir como pieza",
+    "addFluidAsPart": "Añadir {system} como pieza",
+    "partAdded": "Añadido",
+    "fromStock": "Del almacén",
+    "addWithoutStock": "Añadir sin artículo del almacén",
+    "takesDrainRefill": "Lleva {amount} al vaciar y rellenar",
+    "takesCapacity": "Lleva {amount} en total",
+    "attribution": "Datos de {source}",
+    "market": "Mercado: {region}",
+    "about": "¿Qué fiabilidad tiene?",
+    "aboutTitle": "Fiabilidad según la fuente",
+    "estimateNote": "Los valores marcados como Estimación se generan con IA y no se han comprobado con la guía del fabricante. Revísalos antes de presupuestar a un cliente.",
+    "lockedNote": "Esta clave muestra los valores de uno en uno y cada uno cuenta como una consulta. Un plan de pago los muestra todos a la vez.",
+    "settings": "Ajustes de la guía de tiempos",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Cotejado con los datos del fabricante",
+      "high": "Verificado",
+      "highHelp": "Verificado por la fuente",
+      "medium": "Media",
+      "mediumHelp": "La fuente da a este valor una confianza media",
+      "estimated": "Estimación",
+      "estimatedHelp": "Generado con IA y no comprobado con la guía del fabricante"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Un técnico añadió mano de obra a este trabajo} other {Un técnico añadió # líneas de mano de obra a este trabajo}}",
     "body": "Se mantiene fuera de la lista mientras editas. Se guardará de todos modos al guardar.",

+ 43 - 0
messages/fr/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatisation",
     "storage": "Fichiers",
     "registry": "Registre des véhicules",
+    "repair": "Données de réparation",
     "other": "Autre",
     "ai": "IA"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Destination de sauvegarde"
+    },
+    "repair": {
+      "labor": "Temps de main-d'œuvre sur les ordres de travail et devis",
+      "fluids": "Types et capacités de fluides"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Une fois par jour, les modèles non actualisés depuis une semaine sont récupérés à nouveau, pour que la page du véhicule n'attende jamais et qu'un nouveau rappel apparaisse dans la semaine. Seuls la marque, le modèle et l'année sont envoyés."
       }
     },
+    "openlaborproject": {
+      "description": "Consultez les temps de main-d’œuvre et les capacités de fluides du véhicule d’un ordre de réparation ou d’un devis, et ajoutez les heures d’une opération comme ligne de main-d’œuvre en un clic. La plupart des données concernent des véhicules du marché américain, et le barème indique à quel marché correspond chaque recherche. Chaque valeur indique la fiabilité estimée par Open Labor Project, et beaucoup sont des estimations générées par IA : vérifiez-les avant de faire un devis au client. Les recherches utilisent la propre clé API de l’atelier. Avec une offre payante d’Open Labor Project, un véhicule consulté une fois est conservé tant que l’offre est active, si bien qu’une nouvelle recherche ne coûte aucun appel.",
+      "fields": {
+        "apiKey": "Clé API",
+        "apiKeyHelp": "Demandez une clé sur openlaborproject.com, rubrique Developers ; elle arrive par e-mail. Une clé gratuite donne 10 appels par jour et affiche les heures une opération à la fois. L'offre Builder et au-delà relèvent la limite et affichent toutes les heures d'un coup.",
+        "apiKeyServerHelp": "Laissez ce champ vide pour utiliser la clé fournie par ce serveur, ou collez votre propre clé d’openlaborproject.com pour utiliser votre propre offre."
+      },
+      "settings": {
+        "minConfidence": "Afficher les temps à partir de",
+        "minConfidenceHelp": "Écarte les temps dont la source est moins sûre. Beaucoup de temps pour les marques européennes sont des estimations par IA : un choix strict peut laisser peu d’opérations.",
+        "confidenceAll": "Tous les niveaux de confiance",
+        "confidenceMedium": "Moyenne et plus",
+        "confidenceHigh": "Vérifié et plus",
+        "confidenceOem": "OEM uniquement",
+        "hoursFrom": "Heures à ajouter",
+        "hoursFromHelp": "Le chiffre qui va sur l’ordre de réparation : le temps barème, ou le haut de la fourchette donnée par Open Labor Project (par exemple 1,6 h au lieu de 1,2 h). Le haut de la fourchette laisse de la marge pour les opérations qui durent plus longtemps.",
+        "hoursBook": "Temps barème",
+        "hoursHigh": "Haut de la fourchette",
+        "adjustPercent": "Ajuster les temps de (%)",
+        "adjustPercentHelp": "Un pourcentage ajouté à chaque temps avant qu’il n’aille sur l’ordre de réparation : 10 fait passer 1,2 h à 1,32 h, -10 le fait passer à 1,08 h. De -50 à 200.",
+        "rounding": "Arrondir les heures au supérieur à",
+        "roundingHelp": "Arrondit au supérieur les heures de chaque opération, après un éventuel ajustement : avec 0,25, 1,32 h devient 1,5 h. Un temps déjà sur un pas reste tel quel.",
+        "roundNone": "Ne pas arrondir",
+        "roundTenth": "0,1 heure",
+        "roundQuarter": "0,25 heure",
+        "roundHalf": "0,5 heure",
+        "fluids": "Afficher les fluides",
+        "fluidsHelp": "Ajoute un onglet Fluides avec capacités, spécifications et intervalles de remplacement. L’ouvrir utilise un appel à part.",
+        "units": "Unités des fluides",
+        "unitsHelp": "Les unités dans lesquelles s’affichent capacités et intervalles.",
+        "unitsBoth": "Les deux",
+        "unitsMetric": "Métrique (L, km)",
+        "unitsUs": "US (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Transcription vocale",
       "description": "La dictée sur un ordre de travail, transcrite par un modèle vocal plutôt que par le navigateur : bien plus précise, dans toutes les langues, et elle connaît le vocabulaire de l'atelier. Indépendant de votre fournisseur d'IA, que vous pouvez garder tel quel. Utilise votre propre clé OpenAI, ou votre propre serveur compatible avec l'API de transcription d'OpenAI. L'enregistrement est envoyé pour être transcrit et n'est pas conservé.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Actualisation des données de rappel",
       "vehicle": "Vérification des rappels pour un nouveau véhicule"
+    },
+    "repair": {
+      "checkPlan": "Vérifier que les recherches enregistrées peuvent être conservées"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/fr/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Les frais d'atelier des Paramètres : {percent} % de la main-d'œuvre de cette intervention. Supprimez la ligne pour les retirer.",
     "shopFeeHintPercentAll": "Les frais d'atelier des Paramètres : {percent} % de la main-d'œuvre et des pièces de cette intervention. Supprimez la ligne pour les retirer."
   },
+  "laborGuide": {
+    "button": "Barème de temps",
+    "title": "Barème de temps",
+    "description": "Temps de main-d'œuvre et fluides de ce véhicule",
+    "tabLabor": "Temps de main-d'œuvre",
+    "tabFluids": "Fluides",
+    "engine": "Moteur",
+    "categories": "Catégories d’opérations",
+    "allJobs": "Toutes les opérations",
+    "search": "Rechercher une opération, par ex. plaquettes de frein",
+    "clearSearch": "Effacer la recherche",
+    "jobCount": "{count, plural, one {# opération} other {# opérations}}",
+    "noMatch": "Aucune opération ne correspond à « {query} »",
+    "noJobs": "Le barème ne liste aucune opération pour ce moteur.",
+    "showAll": "Afficher toutes les opérations",
+    "showHours": "Voir les heures",
+    "show": "Voir",
+    "lockedValue": "Non affiché avec cette clé",
+    "hours": "{hours} h",
+    "range": "{low} à {high} h",
+    "bookTime": "Barème {hours} h",
+    "add": "Ajouter comme ligne de main-d’œuvre",
+    "added": "Ajouté",
+    "addJob": "Ajouter {job} comme ligne de main-d'œuvre",
+    "addedJob": "{job} est ajouté",
+    "addedCount": "{count, plural, one {# opération ajoutée} other {# opérations ajoutées}}",
+    "partsAddedCount": "{count, plural, one {# pièce ajoutée} other {# pièces ajoutées}}",
+    "done": "Terminé",
+    "loading": "Recherche du véhicule",
+    "stillLooking": "Recherche en cours. Open Labor Project peut mettre jusqu’à une demi-minute pour un véhicule sur lequel il a peu de données.",
+    "notFoundTitle": "Absent du barème",
+    "notFound": "Le barème n’a aucune donnée pour ce véhicule. Vérifiez que la marque, le modèle et l’année sont écrits comme le constructeur les nomme, ou ajoutez le VIN.",
+    "fluidsNotFoundTitle": "Aucun fluide pour ce véhicule",
+    "fluidsNotFound": "Open Labor Project n’a pas encore de données de fluides pour ce véhicule.",
+    "failedTitle": "La recherche n’a pas abouti",
+    "failed": "La recherche a échoué",
+    "unlockFailed": "Impossible d'obtenir cette valeur",
+    "retry": "Réessayer",
+    "matchNone": "Aucun des moteurs listés ne correspond à celui enregistré pour ce véhicule : ces temps peuvent concerner un autre moteur. Choisissez ci-dessus le moteur le plus proche.",
+    "matchFuel": "Le moteur a été choisi d’après le carburant. Vérifiez que c’est le bon.",
+    "matchUnknown": "Aucune cylindrée n’est enregistrée pour ce véhicule : vérifiez que le moteur ci-dessus est le bon.",
+    "hiddenByConfidence": "{count, plural, one {# temps sous votre confiance minimale est masqué.} other {# temps sous votre confiance minimale sont masqués.}}",
+    "changeSettings": "Modifier",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Capacité",
+    "drainRefill": "Vidange et remplissage",
+    "interval": "Remplacer tous les",
+    "copySpec": "Copier {spec}",
+    "copied": "Copié",
+    "noFluids": "Le barème ne liste aucun fluide pour ce moteur.",
+    "addAsPart": "Ajouter comme pièce",
+    "addFluidAsPart": "Ajouter {system} comme pièce",
+    "partAdded": "Ajouté",
+    "fromStock": "En stock",
+    "addWithoutStock": "Ajouter sans article du stock",
+    "takesDrainRefill": "Prend {amount} à la vidange",
+    "takesCapacity": "Prend {amount} au total",
+    "attribution": "Données de {source}",
+    "market": "Marché : {region}",
+    "about": "Quelle fiabilité ?",
+    "aboutTitle": "Fiabilité selon la source",
+    "estimateNote": "Les valeurs marquées Estimation sont générées par IA et ne sont pas vérifiées par rapport aux données du constructeur. Vérifiez-les avant de faire un devis à un client.",
+    "lockedNote": "Cette clé affiche les valeurs une par une, et chacune compte comme un appel. Une offre payante les affiche toutes en une fois.",
+    "settings": "Réglages du barème de temps",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Rapproché des données du constructeur",
+      "high": "Vérifié",
+      "highHelp": "Vérifié par la source",
+      "medium": "Moyenne",
+      "mediumHelp": "La source attribue une confiance moyenne à cette valeur",
+      "estimated": "Estimation",
+      "estimatedHelp": "Généré par IA et non vérifié par rapport aux données du constructeur"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Un technicien a ajouté de la main-d’œuvre à cette intervention} other {Un technicien a ajouté # lignes de main-d’œuvre à cette intervention}}",
     "body": "Elle reste hors de la liste pendant votre saisie. Votre prochain enregistrement l’inclura de toute façon.",

+ 43 - 0
messages/it/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automazione",
     "storage": "File",
     "registry": "Registro veicoli",
+    "repair": "Dati di riparazione",
     "other": "Altro",
     "ai": "IA"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Destinazione dei backup"
+    },
+    "repair": {
+      "labor": "Tempi di manodopera su ordini di lavoro e preventivi",
+      "fluids": "Tipi e capacità dei fluidi"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Una volta al giorno i modelli non aggiornati nell'ultima settimana vengono richiesti di nuovo, così la pagina del veicolo non attende mai e un nuovo richiamo compare entro una settimana. Vengono inviati solo marca, modello e anno."
       }
     },
+    "openlaborproject": {
+      "description": "Consulta i tempi di manodopera e le capacità dei fluidi del veicolo di un ordine di lavoro o di un preventivo, e aggiungi le ore di un lavoro come riga di manodopera con un clic. La maggior parte dei dati riguarda veicoli del mercato statunitense, e il tempario indica a quale mercato si riferisce ogni ricerca. Ogni valore mostra quanto Open Labor Project ne è sicuro, e molti sono stime generate dall’IA, quindi verificali prima di fare un preventivo al cliente. Le ricerche usano la chiave API dell’officina. Con un piano a pagamento di Open Labor Project, un veicolo cercato una volta resta salvato finché il piano è attivo, quindi cercarlo di nuovo non costa alcuna chiamata.",
+      "fields": {
+        "apiKey": "Chiave API",
+        "apiKeyHelp": "Richiedi una chiave su openlaborproject.com alla voce Developers; arriva via email. Una chiave gratuita ha 10 chiamate al giorno e mostra le ore un lavoro alla volta. Dal piano Builder in su il limite sale e le ore di tutti i lavori compaiono insieme.",
+        "apiKeyServerHelp": "Lascia vuoto per usare la chiave fornita da questo server, oppure incolla la tua chiave di openlaborproject.com per usare il tuo piano."
+      },
+      "settings": {
+        "minConfidence": "Mostra i tempi da",
+        "minConfidenceHelp": "Esclude i tempi di cui la fonte è meno sicura. Molti tempi per le marche europee sono stime dell’IA, quindi una scelta rigida può lasciare pochi lavori.",
+        "confidenceAll": "Ogni livello di affidabilità",
+        "confidenceMedium": "Media e oltre",
+        "confidenceHigh": "Verificato e oltre",
+        "confidenceOem": "Solo OEM",
+        "hoursFrom": "Ore da aggiungere",
+        "hoursFromHelp": "Quale valore va sull’ordine di lavoro: il tempo del tempario, o il massimo dell’intervallo indicato da Open Labor Project (ad esempio 1,6 h invece di 1,2 h). Il massimo lascia margine per i lavori che si allungano.",
+        "hoursBook": "Tempo del tempario",
+        "hoursHigh": "Massimo dell’intervallo",
+        "adjustPercent": "Regola i tempi del (%)",
+        "adjustPercentHelp": "Una percentuale aggiunta a ogni tempo prima che vada sull’ordine di lavoro: 10 trasforma 1,2 h in 1,32 h, -10 in 1,08 h. Da -50 a 200.",
+        "rounding": "Arrotonda le ore per eccesso a",
+        "roundingHelp": "Arrotonda per eccesso le ore di ogni lavoro, dopo l’eventuale regolazione: con 0,25, 1,32 h diventa 1,5 h. Un tempo che è già su un passo resta com’è.",
+        "roundNone": "Non arrotondare",
+        "roundTenth": "0,1 ora",
+        "roundQuarter": "0,25 ore",
+        "roundHalf": "0,5 ore",
+        "fluids": "Mostra le specifiche dei fluidi",
+        "fluidsHelp": "Aggiunge una scheda Fluidi con capacità, specifiche e intervalli di sostituzione. Aprirla usa una chiamata a parte.",
+        "units": "Unità dei fluidi",
+        "unitsHelp": "Le unità in cui sono mostrati capacità e intervalli di sostituzione.",
+        "unitsBoth": "Entrambe",
+        "unitsMetric": "Metriche (L, km)",
+        "unitsUs": "USA (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Da voce a testo",
       "description": "La dettatura su un ordine di lavoro viene trascritta da un modello vocale invece che dal browser: molto più precisa, in qualsiasi lingua e conosce le parole dell'officina. È separata dal tuo fornitore di IA, quindi puoi lasciarlo com'è. Usa la tua chiave OpenAI o un tuo server che parli l'API di trascrizione di OpenAI. La registrazione viene inviata per la trascrizione e non viene conservata.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Aggiornamento dei dati sui richiami",
       "vehicle": "Controllo richiami per un nuovo veicolo"
+    },
+    "repair": {
+      "checkPlan": "Verifica che le ricerche salvate possano restare"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/it/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Il contributo officina dalle Impostazioni: {percent} % della manodopera di questo lavoro. Elimina la riga per toglierlo.",
     "shopFeeHintPercentAll": "Il contributo officina dalle Impostazioni: {percent} % di manodopera e ricambi di questo lavoro. Elimina la riga per toglierlo."
   },
+  "laborGuide": {
+    "button": "Tempari",
+    "title": "Tempari",
+    "description": "Tempi di manodopera e fluidi per questo veicolo",
+    "tabLabor": "Tempi di manodopera",
+    "tabFluids": "Fluidi",
+    "engine": "Motore",
+    "categories": "Categorie di lavoro",
+    "allJobs": "Tutti i lavori",
+    "search": "Cerca lavori, ad es. pastiglie freno",
+    "clearSearch": "Cancella ricerca",
+    "jobCount": "{count, plural, one {# lavoro} other {# lavori}}",
+    "noMatch": "Nessun lavoro corrisponde a «{query}»",
+    "noJobs": "Il tempario non elenca lavori per questo motore.",
+    "showAll": "Mostra tutti i lavori",
+    "showHours": "Mostra ore",
+    "show": "Mostra",
+    "lockedValue": "Non mostrato con questa chiave",
+    "hours": "{hours} h",
+    "range": "da {low} a {high} h",
+    "bookTime": "Tempario {hours} h",
+    "add": "Aggiungi come riga di manodopera",
+    "added": "Aggiunto",
+    "addJob": "Aggiungi {job} come riga di manodopera",
+    "addedJob": "{job} è stato aggiunto",
+    "addedCount": "{count, plural, one {# lavoro aggiunto} other {# lavori aggiunti}}",
+    "partsAddedCount": "{count, plural, one {# ricambio aggiunto} other {# ricambi aggiunti}}",
+    "done": "Fatto",
+    "loading": "Ricerca del veicolo",
+    "stillLooking": "Ricerca ancora in corso. Open Labor Project può impiegare fino a mezzo minuto per un veicolo su cui ha pochi dati.",
+    "notFoundTitle": "Non presente nel tempario",
+    "notFound": "Il tempario non ha dati per questo veicolo. Verifica che marca, modello e anno siano scritti come li chiama il costruttore, oppure aggiungi il VIN.",
+    "fluidsNotFoundTitle": "Nessuna specifica dei fluidi per questo veicolo",
+    "fluidsNotFound": "Open Labor Project non ha ancora specifiche dei fluidi per questo veicolo.",
+    "failedTitle": "La ricerca non è andata a buon fine",
+    "failed": "La ricerca non è riuscita",
+    "unlockFailed": "Impossibile ottenere quel valore",
+    "retry": "Riprova",
+    "matchNone": "Nessuno dei motori elencati corrisponde a quello registrato per questo veicolo, quindi questi tempi potrebbero riferirsi a un altro motore. Scegli sopra il motore più simile.",
+    "matchFuel": "Il motore è stato scelto in base al carburante. Verifica che sia quello giusto.",
+    "matchUnknown": "Questo veicolo non ha la cilindrata registrata, quindi verifica che il motore sopra sia quello giusto.",
+    "hiddenByConfidence": "{count, plural, one {# tempo sotto la tua affidabilità minima è nascosto.} other {# tempi sotto la tua affidabilità minima sono nascosti.}}",
+    "changeSettings": "Cambia",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Capacità",
+    "drainRefill": "Scarico e riempimento",
+    "interval": "Sostituire ogni",
+    "copySpec": "Copia {spec}",
+    "copied": "Copiato",
+    "noFluids": "Il tempario non elenca fluidi per questo motore.",
+    "addAsPart": "Aggiungi come ricambio",
+    "addFluidAsPart": "Aggiungi {system} come ricambio",
+    "partAdded": "Aggiunto",
+    "fromStock": "Da magazzino",
+    "addWithoutStock": "Aggiungi senza articolo di magazzino",
+    "takesDrainRefill": "Ne servono {amount} a scarico e riempimento",
+    "takesCapacity": "Ne contiene {amount} in tutto",
+    "attribution": "Dati da {source}",
+    "market": "Mercato: {region}",
+    "about": "Quanto è affidabile?",
+    "aboutTitle": "Affidabilità secondo la fonte",
+    "estimateNote": "I valori segnati come Stima sono generati dall'IA e non verificati con i dati del costruttore. Controllali prima di fare un preventivo a un cliente.",
+    "lockedNote": "Questa chiave mostra i valori uno alla volta e ognuno conta come una chiamata. Un piano a pagamento li mostra tutti insieme.",
+    "settings": "Impostazioni del tempario",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Confrontato con i dati del costruttore",
+      "high": "Verificato",
+      "highHelp": "Verificato dalla fonte",
+      "medium": "Media",
+      "mediumHelp": "La fonte valuta questo valore con affidabilità media",
+      "estimated": "Stima",
+      "estimatedHelp": "Generato dall'IA e non verificato con i dati del costruttore"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Un tecnico ha aggiunto manodopera a questo lavoro} other {Un tecnico ha aggiunto # righe di manodopera a questo lavoro}}",
     "body": "Resta fuori dall’elenco mentre modifichi. Verrà salvata comunque al prossimo salvataggio.",

+ 43 - 0
messages/lt/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatizavimas",
     "storage": "Failai",
     "registry": "Transporto priemonių registras",
+    "repair": "Remonto duomenys",
     "other": "Kita",
     "ai": "DI"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Atsarginių kopijų paskirties vieta"
+    },
+    "repair": {
+      "labor": "Darbo laiko normos užsakymuose ir pasiūlymuose",
+      "fluids": "Skysčių tipai ir talpos"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Kartą per dieną modeliai, neatnaujinti per pastarąją savaitę, užklausiami iš naujo, kad transporto priemonės puslapiui niekada nereikėtų laukti, o naujas atšaukimas pasirodytų per savaitę. Siunčiami tik markė, modelis ir metai."
       }
     },
+    "openlaborproject": {
+      "description": "Peržiūrėkite darbo laiko normas ir skysčių talpas užsakymo ar pasiūlymo transporto priemonei ir vienu paspaudimu pridėkite darbo valandas kaip darbo eilutę. Dauguma duomenų skirti JAV rinkos transporto priemonėms, o normos rodo, kuriai rinkai taikoma kiekviena užklausa. Kiekviena reikšmė rodo, kiek Open Labor Project ja tikras, o daugelis yra DI įverčiai, todėl patikrinkite juos prieš pateikdami pasiūlymą klientui. Užklausoms naudojamas paties serviso API raktas. Turint mokamą Open Labor Project planą, kartą surasta transporto priemonė saugoma tol, kol galioja planas, todėl pakartotinė paieška nekainuoja užklausos.",
+      "fields": {
+        "apiKey": "API raktas",
+        "apiKeyHelp": "Raktą užsisakykite openlaborproject.com skiltyje Developers; jis atsiunčiamas el. paštu. Nemokamas raktas turi 10 užklausų per dieną ir rodo valandas po vieną darbą. Nuo Builder plano limitas didesnis, o visų darbų valandos rodomos iš karto.",
+        "apiKeyServerHelp": "Palikite tuščią, kad naudotumėte šio serverio suteikiamą raktą, arba įklijuokite savo raktą iš openlaborproject.com, kad naudotumėte savo planą."
+      },
+      "settings": {
+        "minConfidence": "Rodyti normas nuo",
+        "minConfidenceHelp": "Praleidžia laikus, kuriais šaltinis mažiau tikras. Daugelis Europos markių laikų yra DI įverčiai, todėl griežtas pasirinkimas gali palikti mažai darbų.",
+        "confidenceAll": "Bet koks patikimumas",
+        "confidenceMedium": "Vidutinis ir aukštesnis",
+        "confidenceHigh": "Patikrinta ir aukštesnis",
+        "confidenceOem": "Tik OEM",
+        "hoursFrom": "Pridedamos valandos",
+        "hoursFromHelp": "Kuri reikšmė patenka į užsakymą: normos laikas ar viršutinė Open Labor Project nurodyto intervalo riba (pavyzdžiui, 1,6 val. vietoj 1,2 val.). Viršutinė riba palieka atsargą darbams, kurie užtrunka ilgiau.",
+        "hoursBook": "Normos laikas",
+        "hoursHigh": "Viršutinė intervalo riba",
+        "adjustPercent": "Koreguoti laikus (%)",
+        "adjustPercentHelp": "Procentas, pridedamas prie kiekvieno laiko prieš jam patenkant į užsakymą: 10 paverčia 1,2 val. į 1,32 val., -10 į 1,08 val. Nuo -50 iki 200.",
+        "rounding": "Apvalinti valandas aukštyn iki",
+        "roundingHelp": "Po korekcijos apvalina kiekvieno darbo valandas aukštyn: su 0,25 iš 1,32 val. gaunama 1,5 val. Laikas, kuris jau yra ant žingsnio, nesikeičia.",
+        "roundNone": "Neapvalinti",
+        "roundTenth": "0,1 val.",
+        "roundQuarter": "0,25 val.",
+        "roundHalf": "0,5 val.",
+        "fluids": "Rodyti skysčių duomenis",
+        "fluidsHelp": "Prideda skirtuką Skysčiai su talpomis, specifikacijomis ir keitimo intervalais. Jo atidarymas naudoja atskirą užklausą.",
+        "units": "Skysčių vienetai",
+        "unitsHelp": "Vienetai, kuriais rodomos talpos ir keitimo intervalai.",
+        "unitsBoth": "Abu",
+        "unitsMetric": "Metriniai (L, km)",
+        "unitsUs": "JAV (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Kalba į tekstą",
       "description": "Diktavimas darbo užsakyme, kurį užrašo kalbos modelis, o ne naršyklė: daug tiksliau, bet kuria kalba, ir jis žino dirbtuvių žodžius. Atskirta nuo jūsų DI paslaugų teikėjo, todėl jį galite palikti tokį, koks yra. Naudojamas jūsų pačių OpenAI raktas arba jūsų serveris, kalbantis OpenAI transkripcijos API. Įrašas siunčiamas užrašyti ir nesaugomas.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Atšaukimų duomenų atnaujinimas",
       "vehicle": "Naujos transporto priemonės atšaukimų patikra"
+    },
+    "repair": {
+      "checkPlan": "Patikrinti, ar išsaugotas užklausas galima laikyti"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/lt/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Dirbtuvių mokestis iš nustatymų: {percent} % šio darbo darbų sumos. Ištrinkite eilutę, kad jo neliktų.",
     "shopFeeHintPercentAll": "Dirbtuvių mokestis iš nustatymų: {percent} % šio darbo darbų ir dalių sumos. Ištrinkite eilutę, kad jo neliktų."
   },
+  "laborGuide": {
+    "button": "Darbo laiko normos",
+    "title": "Darbo laiko normos",
+    "description": "Darbo laiko normos ir skysčiai šiai transporto priemonei",
+    "tabLabor": "Darbo laikas",
+    "tabFluids": "Skysčiai",
+    "engine": "Variklis",
+    "categories": "Darbų kategorijos",
+    "allJobs": "Visi darbai",
+    "search": "Ieškoti darbų, pvz., stabdžių kaladėlės",
+    "clearSearch": "Išvalyti paiešką",
+    "jobCount": "{count, plural, one {# darbas} few {# darbai} many {# darbo} other {# darbų}}",
+    "noMatch": "Nėra darbų, atitinkančių „{query}“",
+    "noJobs": "Normose šiam varikliui darbų nėra.",
+    "showAll": "Rodyti visus darbus",
+    "showHours": "Rodyti valandas",
+    "show": "Rodyti",
+    "lockedValue": "Su šiuo raktu nerodoma",
+    "hours": "{hours} val.",
+    "range": "nuo {low} iki {high} val.",
+    "bookTime": "Norma {hours} val.",
+    "add": "Pridėti kaip darbo eilutę",
+    "added": "Pridėta",
+    "addJob": "Pridėti {job} kaip darbo eilutę",
+    "addedJob": "{job} pridėta",
+    "addedCount": "{count, plural, one {Pridėtas # darbas} few {Pridėti # darbai} many {Pridėta # darbo} other {Pridėta # darbų}}",
+    "partsAddedCount": "{count, plural, one {Pridėta # dalis} few {Pridėtos # dalys} many {Pridėta # dalies} other {Pridėta # dalių}}",
+    "done": "Baigta",
+    "loading": "Ieškoma transporto priemonės",
+    "stillLooking": "Vis dar ieškoma. Transporto priemonei, apie kurią mažai duomenų, Open Labor Project atsakyti gali užtrukti iki pusės minutės.",
+    "notFoundTitle": "Normose nėra",
+    "notFound": "Darbo laiko normose nėra duomenų apie šią transporto priemonę. Patikrinkite, ar markė, modelis ir metai užrašyti taip, kaip juos vadina gamintojas, arba pridėkite VIN.",
+    "fluidsNotFoundTitle": "Šiai transporto priemonei skysčių duomenų nėra",
+    "fluidsNotFound": "Open Labor Project kol kas neturi šios transporto priemonės skysčių duomenų.",
+    "failedTitle": "Užklausa nepavyko",
+    "failed": "Paieška nepavyko",
+    "unlockFailed": "Nepavyko gauti šios reikšmės",
+    "retry": "Bandyti dar kartą",
+    "matchNone": "Nė vienas išvardytas variklis neatitinka šiai transporto priemonei nurodyto, todėl laikai gali būti kito variklio. Viršuje pasirinkite artimiausią variklį.",
+    "matchFuel": "Variklis parinktas pagal kuro tipą. Patikrinkite, ar jis tinkamas.",
+    "matchUnknown": "Šiai transporto priemonei nenurodytas variklio tūris, todėl patikrinkite, ar viršuje pasirinktas tinkamas variklis.",
+    "hiddenByConfidence": "{count, plural, one {Paslėptas # laikas, žemesnis už jūsų minimalų patikimumą.} few {Paslėpti # laikai, žemesni už jūsų minimalų patikimumą.} many {Paslėpta # laiko, žemesnio už jūsų minimalų patikimumą.} other {Paslėpta # laikų, žemesnių už jūsų minimalų patikimumą.}}",
+    "changeSettings": "Keisti",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Talpa",
+    "drainRefill": "Išleidimas ir pripildymas",
+    "interval": "Keisti kas",
+    "copySpec": "Kopijuoti {spec}",
+    "copied": "Nukopijuota",
+    "noFluids": "Normose šiam varikliui skysčių nėra.",
+    "addAsPart": "Pridėti kaip dalį",
+    "addFluidAsPart": "Pridėti {system} kaip dalį",
+    "partAdded": "Pridėta",
+    "fromStock": "Iš sandėlio",
+    "addWithoutStock": "Pridėti be sandėlio prekės",
+    "takesDrainRefill": "Išleidžiant ir pripildant reikia {amount}",
+    "takesCapacity": "Iš viso telpa {amount}",
+    "attribution": "Duomenys: {source}",
+    "market": "Rinka: {region}",
+    "about": "Kiek tai patikima?",
+    "aboutTitle": "Patikimumas pagal šaltinį",
+    "estimateNote": "Įverčiu pažymėtos reikšmės sugeneruotos DI ir nepatikrintos pagal gamintojo duomenis. Patikrinkite jas prieš teikdami pasiūlymą klientui.",
+    "lockedNote": "Šis raktas rodo reikšmes po vieną, ir kiekviena skaičiuojama kaip užklausa. Mokamas planas rodo visas iš karto.",
+    "settings": "Darbo laiko normų nustatymai",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Sutikrinta su gamintojo duomenimis",
+      "high": "Patikrinta",
+      "highHelp": "Patikrinta šaltinio",
+      "medium": "Vidutinis",
+      "mediumHelp": "Šaltinis šią reikšmę vertina kaip vidutiniškai patikimą",
+      "estimated": "Įvertis",
+      "estimatedHelp": "Sugeneruota DI ir nepatikrinta pagal gamintojo duomenis"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Mechanikas pridėjo # darbo eilutę prie šio užsakymo} few {Mechanikas pridėjo # darbo eilutes prie šio užsakymo} many {Mechanikas pridėjo # darbo eilutės prie šio užsakymo} other {Mechanikas pridėjo # darbo eilučių prie šio užsakymo}}",
     "body": "Kol redaguojate, ji nerodoma sąraše. Kitą kartą išsaugant ji bus įtraukta.",

+ 43 - 0
messages/nb/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatisering",
     "storage": "Filer",
     "registry": "Kjøretøyregister",
+    "repair": "Verkstedsdata",
     "other": "Annet",
     "ai": "KI"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Mål for sikkerhetskopier"
+    },
+    "repair": {
+      "labor": "Normtider på arbeidsordrer og tilbud",
+      "fluids": "Væsketyper og mengder"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "En gang om dagen hentes modeller som ikke er oppdatert siste uke på nytt, så kjøretøysiden aldri venter og en ny tilbakekalling vises innen en uke. Bare merke, modell og år sendes."
       }
     },
+    "openlaborproject": {
+      "description": "Slå opp normtider og væskemengder for kjøretøyet på en arbeidsordre eller et tilbud, og legg til en jobbs timer som en arbeidslinje med ett klikk. Det meste av dataene gjelder kjøretøy for det amerikanske markedet, og guiden viser hvilket marked hvert oppslag gjelder. Hver verdi viser hvor sikker Open Labor Project er, og mange er anslag laget med KI, så sjekk dem før du gir kunden et tilbud. Oppslag bruker verkstedets egen API-nøkkel. Med et betalt abonnement hos Open Labor Project lagres et kjøretøy som er slått opp så lenge abonnementet varer, så et nytt oppslag koster ikke et kall.",
+      "fields": {
+        "apiKey": "API-nøkkel",
+        "apiKeyHelp": "Be om en nøkkel på openlaborproject.com under Developers; den kommer på e-post. En gratis nøkkel har 10 kall per dag og viser timer for én jobb om gangen. Builder-abonnementet og oppover gir høyere grense og viser timene for alle jobber på en gang.",
+        "apiKeyServerHelp": "La feltet stå tomt for å bruke nøkkelen denne serveren har, eller lim inn din egen nøkkel fra openlaborproject.com for å bruke ditt eget abonnement."
+      },
+      "settings": {
+        "minConfidence": "Vis normtider fra",
+        "minConfidenceHelp": "Utelat tider kilden er mindre sikker på. Mange tider for europeiske merker er anslag laget med KI, så et strengt valg kan gi få jobber.",
+        "confidenceAll": "Alle sikkerhetsnivåer",
+        "confidenceMedium": "Middels og høyere",
+        "confidenceHigh": "Bekreftet og høyere",
+        "confidenceOem": "Bare OEM",
+        "hoursFrom": "Timer som legges til",
+        "hoursFromHelp": "Hvilket tall som havner på arbeidsordren: normtiden, eller toppen av intervallet Open Labor Project oppgir (for eksempel 1,6 t i stedet for 1,2 t). Toppen av intervallet gir rom for jobber som tar lengre tid.",
+        "hoursBook": "Normtid",
+        "hoursHigh": "Toppen av intervallet",
+        "adjustPercent": "Juster tidene med (%)",
+        "adjustPercentHelp": "En prosent som legges til hver tid før den havner på arbeidsordren: 10 gjør 1,2 t til 1,32 t, -10 gjør den til 1,08 t. Fra -50 til 200.",
+        "rounding": "Rund timer opp til",
+        "roundingHelp": "Runder hver jobbs timer opp etter en eventuell justering: med 0,25 blir 1,32 t til 1,5 t. En tid som allerede ligger på et steg, blir stående.",
+        "roundNone": "Ikke rund av",
+        "roundTenth": "0,1 time",
+        "roundQuarter": "0,25 time",
+        "roundHalf": "0,5 time",
+        "fluids": "Vis væskespesifikasjoner",
+        "fluidsHelp": "Legger til en Væsker-fane med mengder, spesifikasjoner og byttintervaller. Å åpne den bruker et eget kall.",
+        "units": "Enheter for væsker",
+        "unitsHelp": "Enhetene mengder og byttintervaller vises i.",
+        "unitsBoth": "Begge",
+        "unitsMetric": "Metrisk (L, km)",
+        "unitsUs": "Amerikansk (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Tale til tekst",
       "description": "Diktering på en arbeidsordre, skrevet ned av en talemodell i stedet for nettleseren: langt mer nøyaktig, alle språk, og den kan verkstedets ord. Atskilt fra AI-leverandøren din, så den kan du beholde som den er. Bruker din egen OpenAI-nøkkel, eller en egen server som snakker OpenAIs API for transkribering. Opptaket sendes for å skrives ned og lagres ikke.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Oppdatering av tilbakekallingsdata",
       "vehicle": "Tilbakekallingssjekk for nytt kjøretøy"
+    },
+    "repair": {
+      "checkPlan": "Sjekk at lagrede oppslag kan beholdes"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/nb/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Verkstedgebyret fra Innstillinger: {percent} % av arbeidet på denne jobben. Slett linjen for å fjerne det.",
     "shopFeeHintPercentAll": "Verkstedgebyret fra Innstillinger: {percent} % av arbeid og deler på denne jobben. Slett linjen for å fjerne det."
   },
+  "laborGuide": {
+    "button": "Normtider",
+    "title": "Normtider",
+    "description": "Normtider og væsker for dette kjøretøyet",
+    "tabLabor": "Normtider",
+    "tabFluids": "Væsker",
+    "engine": "Motor",
+    "categories": "Jobbkategorier",
+    "allJobs": "Alle jobber",
+    "search": "Søk etter jobber, for eksempel bremseklosser",
+    "clearSearch": "Tøm søket",
+    "jobCount": "{count, plural, one {# jobb} other {# jobber}}",
+    "noMatch": "Ingen jobber passer med «{query}»",
+    "noJobs": "Guiden har ingen jobber for denne motoren.",
+    "showAll": "Vis alle jobber",
+    "showHours": "Vis timer",
+    "show": "Vis",
+    "lockedValue": "Vises ikke med denne nøkkelen",
+    "hours": "{hours} t",
+    "range": "{low} til {high} t",
+    "bookTime": "Normtid {hours} t",
+    "add": "Legg til som arbeidslinje",
+    "added": "Lagt til",
+    "addJob": "Legg til {job} som arbeidslinje",
+    "addedJob": "{job} er lagt til",
+    "addedCount": "{count, plural, one {# jobb lagt til} other {# jobber lagt til}}",
+    "partsAddedCount": "{count, plural, one {# del lagt til} other {# deler lagt til}}",
+    "done": "Ferdig",
+    "loading": "Slår opp kjøretøyet",
+    "stillLooking": "Leter fortsatt. Open Labor Project kan bruke opptil et halvt minutt på et kjøretøy det har lite data om.",
+    "notFoundTitle": "Ikke i guiden",
+    "notFound": "Guiden har ingenting om dette kjøretøyet. Sjekk at merke, modell og årsmodell er skrevet slik produsenten skriver dem, eller legg inn VIN.",
+    "fluidsNotFoundTitle": "Ingen væskespesifikasjoner for dette kjøretøyet",
+    "fluidsNotFound": "Open Labor Project har ingen væskespesifikasjoner for dette kjøretøyet ennå.",
+    "failedTitle": "Oppslaget gikk ikke gjennom",
+    "failed": "Oppslaget feilet",
+    "unlockFailed": "Kunne ikke hente den verdien",
+    "retry": "Prøv igjen",
+    "matchNone": "Ingen av motorene i listen passer med den som er registrert på kjøretøyet, så tidene kan gjelde en annen motor. Velg den nærmeste motoren over.",
+    "matchFuel": "Motoren er valgt ut fra drivstofftypen. Sjekk at det er riktig motor.",
+    "matchUnknown": "Kjøretøyet har ikke registrert motorstørrelse, så sjekk at motoren over er riktig.",
+    "hiddenByConfidence": "{count, plural, one {# tid under minste sikkerhet er skjult.} other {# tider under minste sikkerhet er skjult.}}",
+    "changeSettings": "Endre",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Mengde",
+    "drainRefill": "Tapping og påfylling",
+    "interval": "Bytt hver",
+    "copySpec": "Kopier {spec}",
+    "copied": "Kopiert",
+    "noFluids": "Guiden har ingen væsker for denne motoren.",
+    "addAsPart": "Legg til som del",
+    "addFluidAsPart": "Legg til {system} som del",
+    "partAdded": "Lagt til",
+    "fromStock": "Fra lager",
+    "addWithoutStock": "Legg til uten lagervare",
+    "takesDrainRefill": "Tar {amount} ved tapping og påfylling",
+    "takesCapacity": "Tar {amount} i alt",
+    "attribution": "Data fra {source}",
+    "market": "{region}-markedet",
+    "about": "Hvor sikkert er dette?",
+    "aboutTitle": "Hvor sikker kilden er",
+    "estimateNote": "Verdier merket Anslag er laget med KI og ikke sjekket mot produsentens data. Sjekk dem før du gir kunden et tilbud.",
+    "lockedNote": "Denne nøkkelen viser verdiene én om gangen, og hver av dem teller som et kall. Et betalt abonnement viser alle på en gang.",
+    "settings": "Innstillinger for normtider",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Sjekket mot produsentens egne data",
+      "high": "Bekreftet",
+      "highHelp": "Bekreftet av kilden",
+      "medium": "Middels",
+      "mediumHelp": "Kilden vurderer denne verdien som middels sikker",
+      "estimated": "Anslag",
+      "estimatedHelp": "Laget med KI og ikke sjekket mot produsentens data"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {En mekaniker la til arbeid på denne jobben} other {En mekaniker la til # arbeidslinjer på denne jobben}}",
     "body": "Den holdes utenfor listen mens du redigerer. Den blir lagret uansett neste gang du lagrer.",

+ 43 - 0
messages/nl/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatisering",
     "storage": "Bestanden",
     "registry": "Voertuigregister",
+    "repair": "Reparatiegegevens",
     "other": "Overig",
     "ai": "AI"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Back-upbestemming"
+    },
+    "repair": {
+      "labor": "Arbeidstijden op werkorders en offertes",
+      "fluids": "Vloeistofsoorten en inhoud"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Eén keer per dag worden modellen die de afgelopen week niet zijn vernieuwd opnieuw opgehaald, zodat de voertuigpagina nooit wacht en een nieuwe terugroepactie binnen een week zichtbaar is. Alleen merk, model en bouwjaar worden verstuurd."
       }
     },
+    "openlaborproject": {
+      "description": "Zoek arbeidstijden en vloeistofinhouden op voor het voertuig van een werkorder of offerte, en voeg de uren van een werkzaamheid met één klik toe als arbeidsregel. De meeste gegevens gelden voor voertuigen voor de Amerikaanse markt, en de gids toont voor welke markt elke opzoeking geldt. Elke waarde toont hoe zeker Open Labor Project ervan is, en veel zijn door AI gemaakte schattingen, dus controleer ze voordat u een klant een offerte geeft. Opzoekingen gebruiken de eigen API-sleutel van de werkplaats. Met een betaald abonnement bij Open Labor Project blijft een eenmaal opgezocht voertuig bewaard zolang het abonnement loopt, zodat opnieuw opzoeken geen aanroep kost.",
+      "fields": {
+        "apiKey": "API-sleutel",
+        "apiKeyHelp": "Vraag een sleutel aan op openlaborproject.com onder Developers; u krijgt hem per e-mail. Een gratis sleutel heeft 10 aanroepen per dag en toont de uren van één klus tegelijk. Vanaf het Builder-abonnement is de limiet hoger en ziet u alle uren in één keer.",
+        "apiKeyServerHelp": "Laat dit leeg om de sleutel van deze server te gebruiken, of plak uw eigen sleutel van openlaborproject.com om uw eigen abonnement te gebruiken."
+      },
+      "settings": {
+        "minConfidence": "Arbeidstijden tonen vanaf",
+        "minConfidenceHelp": "Laat tijden weg waarvan de bron minder zeker is. Veel tijden voor Europese merken zijn AI-schattingen, dus een strenge keuze kan weinig werkzaamheden overlaten.",
+        "confidenceAll": "Elk zekerheidsniveau",
+        "confidenceMedium": "Gemiddeld en hoger",
+        "confidenceHigh": "Geverifieerd en hoger",
+        "confidenceOem": "Alleen OEM",
+        "hoursFrom": "Uren om toe te voegen",
+        "hoursFromHelp": "Welk getal op de werkorder komt: de normtijd, of de bovenkant van het bereik dat Open Labor Project geeft (bijvoorbeeld 1,6 u in plaats van 1,2 u). De bovenkant laat ruimte voor werkzaamheden die uitlopen.",
+        "hoursBook": "Normtijd",
+        "hoursHigh": "Bovenkant van het bereik",
+        "adjustPercent": "Tijden aanpassen met (%)",
+        "adjustPercentHelp": "Een percentage dat bij elke tijd wordt opgeteld voordat die op de werkorder komt: 10 maakt van 1,2 u 1,32 u, -10 maakt er 1,08 u van. Van -50 tot 200.",
+        "rounding": "Uren naar boven afronden op",
+        "roundingHelp": "Rondt de uren van elke werkzaamheid naar boven af, na een eventuele aanpassing: met 0,25 wordt 1,32 u 1,5 u. Een tijd die al op een stap ligt, blijft zoals hij is.",
+        "roundNone": "Niet afronden",
+        "roundTenth": "0,1 uur",
+        "roundQuarter": "0,25 uur",
+        "roundHalf": "0,5 uur",
+        "fluids": "Vloeistofgegevens tonen",
+        "fluidsHelp": "Voegt een tabblad Vloeistoffen toe met inhouden, specificaties en vervangingsintervallen. Openen kost een eigen aanroep.",
+        "units": "Eenheden voor vloeistoffen",
+        "unitsHelp": "De eenheden waarin inhouden en vervangingsintervallen worden getoond.",
+        "unitsBoth": "Beide",
+        "unitsMetric": "Metrisch (L, km)",
+        "unitsUs": "VS (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Spraak naar tekst",
       "description": "Dicteren op een werkorder, uitgeschreven door een spraakmodel in plaats van door de browser: veel nauwkeuriger, in elke taal, en het kent de woorden van een werkplaats. Los van uw AI-provider, dus die kunt u laten zoals hij is. Gebruikt uw eigen OpenAI-sleutel, of een eigen server die de transcriptie-API van OpenAI spreekt. De opname wordt alleen verstuurd om uit te schrijven en wordt niet bewaard.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Vernieuwing van terugroepgegevens",
       "vehicle": "Terugroepcontrole voor een nieuw voertuig"
+    },
+    "repair": {
+      "checkPlan": "Controleren of bewaarde opzoekingen mogen blijven"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/nl/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "De werkplaatstoeslag uit Instellingen: {percent} % van de arbeid op deze klus. Verwijder de regel om hem weg te laten.",
     "shopFeeHintPercentAll": "De werkplaatstoeslag uit Instellingen: {percent} % van de arbeid en onderdelen op deze klus. Verwijder de regel om hem weg te laten."
   },
+  "laborGuide": {
+    "button": "Arbeidstijden",
+    "title": "Arbeidstijden",
+    "description": "Standaardtijden en vloeistoffen voor dit voertuig",
+    "tabLabor": "Arbeidstijden",
+    "tabFluids": "Vloeistoffen",
+    "engine": "Motor",
+    "categories": "Categorieën",
+    "allJobs": "Alle werkzaamheden",
+    "search": "Zoek werkzaamheden, bijvoorbeeld remblokken",
+    "clearSearch": "Zoekopdracht wissen",
+    "jobCount": "{count, plural, one {# werkzaamheid} other {# werkzaamheden}}",
+    "noMatch": "Geen werkzaamheden passen bij ‘{query}’",
+    "noJobs": "De gids noemt geen werkzaamheden voor deze motor.",
+    "showAll": "Alle werkzaamheden tonen",
+    "showHours": "Uren tonen",
+    "show": "Tonen",
+    "lockedValue": "Niet zichtbaar met deze sleutel",
+    "hours": "{hours} u",
+    "range": "{low} tot {high} u",
+    "bookTime": "Normtijd {hours} u",
+    "add": "Toevoegen als arbeidsregel",
+    "added": "Toegevoegd",
+    "addJob": "{job} toevoegen als arbeidsregel",
+    "addedJob": "{job} is toegevoegd",
+    "addedCount": "{count, plural, one {# werkzaamheid toegevoegd} other {# werkzaamheden toegevoegd}}",
+    "partsAddedCount": "{count, plural, one {# onderdeel toegevoegd} other {# onderdelen toegevoegd}}",
+    "done": "Klaar",
+    "loading": "Voertuig opzoeken",
+    "stillLooking": "Nog aan het zoeken. Open Labor Project kan tot een halve minuut nodig hebben voor een voertuig waarover het weinig gegevens heeft.",
+    "notFoundTitle": "Niet in de gids",
+    "notFound": "De tijdengids heeft geen gegevens voor dit voertuig. Controleer of merk, model en bouwjaar geschreven zijn zoals de fabrikant ze noemt, of voeg het VIN toe.",
+    "fluidsNotFoundTitle": "Geen vloeistofgegevens voor dit voertuig",
+    "fluidsNotFound": "Open Labor Project heeft nog geen vloeistofgegevens voor dit voertuig.",
+    "failedTitle": "Het opzoeken is niet gelukt",
+    "failed": "Het opzoeken is mislukt",
+    "unlockFailed": "Die waarde kon niet worden opgehaald",
+    "retry": "Opnieuw proberen",
+    "matchNone": "Geen van de vermelde motoren past bij de motor die voor dit voertuig is vastgelegd, dus deze tijden kunnen voor een andere motor gelden. Kies hierboven de motor die het dichtst in de buurt komt.",
+    "matchFuel": "De motor is gekozen op brandstofsoort. Controleer of het de juiste is.",
+    "matchUnknown": "Voor dit voertuig is geen cilinderinhoud vastgelegd, dus controleer of de motor hierboven de juiste is.",
+    "hiddenByConfidence": "{count, plural, one {# tijd onder uw minimale zekerheid is verborgen.} other {# tijden onder uw minimale zekerheid zijn verborgen.}}",
+    "changeSettings": "Wijzigen",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Inhoud",
+    "drainRefill": "Aftappen en vullen",
+    "interval": "Vervangen elke",
+    "copySpec": "{spec} kopiëren",
+    "copied": "Gekopieerd",
+    "noFluids": "De gids noemt geen vloeistoffen voor deze motor.",
+    "addAsPart": "Toevoegen als onderdeel",
+    "addFluidAsPart": "{system} toevoegen als onderdeel",
+    "partAdded": "Toegevoegd",
+    "fromStock": "Uit voorraad",
+    "addWithoutStock": "Toevoegen zonder voorraadartikel",
+    "takesDrainRefill": "Neemt {amount} bij aftappen en bijvullen",
+    "takesCapacity": "Neemt {amount} in totaal",
+    "attribution": "Gegevens van {source}",
+    "market": "Markt: {region}",
+    "about": "Hoe zeker is dit?",
+    "aboutTitle": "Hoe zeker de bron is",
+    "estimateNote": "Waarden met Schatting zijn door AI gemaakt en niet gecontroleerd aan de gegevens van de fabrikant. Controleer ze voordat u een klant een offerte geeft.",
+    "lockedNote": "Deze sleutel toont waarden één voor één, en elke waarde telt als een aanroep. Een betaald abonnement toont ze allemaal tegelijk.",
+    "settings": "Instellingen voor arbeidstijden",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Vergeleken met de gegevens van de fabrikant",
+      "high": "Geverifieerd",
+      "highHelp": "Geverifieerd door de bron",
+      "medium": "Gemiddeld",
+      "mediumHelp": "De bron geeft deze waarde gemiddelde zekerheid",
+      "estimated": "Schatting",
+      "estimatedHelp": "Door AI gemaakt en niet gecontroleerd aan de gegevens van de fabrikant"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Een monteur heeft arbeid aan deze order toegevoegd} other {Een monteur heeft # arbeidsregels aan deze order toegevoegd}}",
     "body": "Die blijft buiten de lijst terwijl je bewerkt. Bij je volgende keer opslaan gaat die sowieso mee.",

+ 43 - 0
messages/pl/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automatyzacja",
     "storage": "Pliki",
     "registry": "Rejestr pojazdów",
+    "repair": "Dane naprawcze",
     "other": "Inne",
     "ai": "SI"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Miejsce docelowe kopii zapasowych"
+    },
+    "repair": {
+      "labor": "Normy czasowe w zleceniach i wycenach",
+      "fluids": "Rodzaje i pojemności płynów"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Raz dziennie modele nieodświeżone w ostatnim tygodniu są pobierane ponownie, aby strona pojazdu nigdy nie czekała, a nowa akcja serwisowa pojawiła się w ciągu tygodnia. Wysyłane są tylko marka, model i rocznik."
       }
     },
+    "openlaborproject": {
+      "description": "Sprawdzaj normy czasowe i pojemności płynów dla pojazdu ze zlecenia lub oferty i dodawaj godziny czynności jako pozycję robocizny jednym kliknięciem. Większość danych dotyczy pojazdów z rynku amerykańskiego, a katalog pokazuje, którego rynku dotyczy każde wyszukiwanie. Każda wartość pokazuje, na ile Open Labor Project jest jej pewien, a wiele to szacunki AI, więc sprawdź je, zanim przedstawisz ofertę klientowi. Wyszukiwania korzystają z własnego klucza API warsztatu. W płatnym planie Open Labor Project raz wyszukany pojazd jest przechowywany tak długo, jak trwa plan, więc ponowne wyszukanie nie kosztuje wywołania.",
+      "fields": {
+        "apiKey": "Klucz API",
+        "apiKeyHelp": "Poproś o klucz na openlaborproject.com w sekcji Developers; przyjdzie e-mailem. Darmowy klucz ma 10 zapytań dziennie i pokazuje godziny jednej czynności naraz. Plan Builder i wyższe podnoszą limit i pokazują godziny wszystkich czynności od razu.",
+        "apiKeyServerHelp": "Zostaw puste, aby użyć klucza udostępnianego przez ten serwer, albo wklej własny klucz z openlaborproject.com, aby korzystać z własnego planu."
+      },
+      "settings": {
+        "minConfidence": "Pokazuj normy od",
+        "minConfidenceHelp": "Pomija czasy, których źródło jest mniej pewne. Wiele czasów dla marek europejskich to szacunki AI, więc surowy wybór może zostawić niewiele czynności.",
+        "confidenceAll": "Każdy poziom pewności",
+        "confidenceMedium": "Średnia i wyżej",
+        "confidenceHigh": "Zweryfikowane i wyżej",
+        "confidenceOem": "Tylko OEM",
+        "hoursFrom": "Dodawane godziny",
+        "hoursFromHelp": "Która wartość trafia do zlecenia: czas z katalogu albo górna granica zakresu podanego przez Open Labor Project (na przykład 1,6 h zamiast 1,2 h). Górna granica zostawia zapas na czynności, które się przeciągają.",
+        "hoursBook": "Czas z katalogu",
+        "hoursHigh": "Górna granica zakresu",
+        "adjustPercent": "Koryguj czasy o (%)",
+        "adjustPercentHelp": "Procent dodawany do każdego czasu, zanim trafi do zlecenia: 10 zmienia 1,2 h w 1,32 h, a -10 w 1,08 h. Od -50 do 200.",
+        "rounding": "Zaokrąglaj godziny w górę do",
+        "roundingHelp": "Zaokrągla w górę godziny każdej czynności, po ewentualnej korekcie: przy 0,25 z 1,32 h robi się 1,5 h. Czas, który już wypada na kroku, zostaje bez zmian.",
+        "roundNone": "Nie zaokrąglaj",
+        "roundTenth": "0,1 godziny",
+        "roundQuarter": "0,25 godziny",
+        "roundHalf": "0,5 godziny",
+        "fluids": "Pokazuj dane płynów",
+        "fluidsHelp": "Dodaje kartę Płyny z pojemnościami, specyfikacjami i okresami wymiany. Otwarcie jej zużywa osobne wywołanie.",
+        "units": "Jednostki płynów",
+        "unitsHelp": "Jednostki, w których pokazywane są pojemności i okresy wymiany.",
+        "unitsBoth": "Obie",
+        "unitsMetric": "Metryczne (L, km)",
+        "unitsUs": "USA (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Mowa na tekst",
       "description": "Dyktowanie w zleceniu zapisywane przez model mowy zamiast przeglądarki: znacznie dokładniej, w każdym języku i ze znajomością słownictwa warsztatowego. Działa niezależnie od Twojego dostawcy AI, więc możesz go zostawić bez zmian. Używa Twojego własnego klucza OpenAI albo własnego serwera obsługującego API transkrypcji OpenAI. Nagranie jest wysyłane do zapisania i nie jest przechowywane.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Odświeżanie danych o akcjach serwisowych",
       "vehicle": "Sprawdzenie akcji serwisowych dla nowego pojazdu"
+    },
+    "repair": {
+      "checkPlan": "Sprawdzenie, czy zapisane wyszukiwania mogą zostać"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/pl/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Opłata warsztatowa z Ustawień: {percent} % robocizny w tym zleceniu. Usuń pozycję, aby ją pominąć.",
     "shopFeeHintPercentAll": "Opłata warsztatowa z Ustawień: {percent} % robocizny i części w tym zleceniu. Usuń pozycję, aby ją pominąć."
   },
+  "laborGuide": {
+    "button": "Normy czasowe",
+    "title": "Normy czasowe",
+    "description": "Normy czasowe i płyny dla tego pojazdu",
+    "tabLabor": "Normy czasowe",
+    "tabFluids": "Płyny",
+    "engine": "Silnik",
+    "categories": "Kategorie czynności",
+    "allJobs": "Wszystkie czynności",
+    "search": "Szukaj czynności, np. klocki hamulcowe",
+    "clearSearch": "Wyczyść wyszukiwanie",
+    "jobCount": "{count, plural, one {# czynność} few {# czynności} many {# czynności} other {# czynności}}",
+    "noMatch": "Żadna czynność nie pasuje do „{query}”",
+    "noJobs": "Katalog nie podaje czynności dla tego silnika.",
+    "showAll": "Pokaż wszystkie czynności",
+    "showHours": "Pokaż godziny",
+    "show": "Pokaż",
+    "lockedValue": "Niewidoczne z tym kluczem",
+    "hours": "{hours} h",
+    "range": "{low} do {high} h",
+    "bookTime": "Norma {hours} h",
+    "add": "Dodaj jako pozycję robocizny",
+    "added": "Dodano",
+    "addJob": "Dodaj {job} jako pozycję robocizny",
+    "addedJob": "Dodano {job}",
+    "addedCount": "{count, plural, one {Dodano # czynność} few {Dodano # czynności} many {Dodano # czynności} other {Dodano # czynności}}",
+    "partsAddedCount": "{count, plural, one {Dodano # część} few {Dodano # części} many {Dodano # części} other {Dodano # części}}",
+    "done": "Gotowe",
+    "loading": "Wyszukiwanie pojazdu",
+    "stillLooking": "Wciąż szukamy. Dla pojazdu, o którym ma mało danych, Open Labor Project może odpowiadać do pół minuty.",
+    "notFoundTitle": "Brak w katalogu",
+    "notFound": "Katalog norm nie ma danych dla tego pojazdu. Sprawdź, czy marka, model i rok są zapisane tak, jak nazywa je producent, albo dodaj VIN.",
+    "fluidsNotFoundTitle": "Brak danych płynów dla tego pojazdu",
+    "fluidsNotFound": "Open Labor Project nie ma jeszcze danych płynów dla tego pojazdu.",
+    "failedTitle": "Wyszukiwanie się nie powiodło",
+    "failed": "Wyszukiwanie nie powiodło się",
+    "unlockFailed": "Nie udało się pobrać tej wartości",
+    "retry": "Spróbuj ponownie",
+    "matchNone": "Żaden z wymienionych silników nie pasuje do silnika zapisanego dla tego pojazdu, więc te czasy mogą dotyczyć innego silnika. Wybierz powyżej najbliższy silnik.",
+    "matchFuel": "Silnik wybrano według rodzaju paliwa. Sprawdź, czy jest właściwy.",
+    "matchUnknown": "Ten pojazd nie ma zapisanej pojemności silnika, więc sprawdź, czy silnik powyżej jest właściwy.",
+    "hiddenByConfidence": "{count, plural, one {Ukryto # czas poniżej Twojej minimalnej pewności.} few {Ukryto # czasy poniżej Twojej minimalnej pewności.} many {Ukryto # czasów poniżej Twojej minimalnej pewności.} other {Ukryto # czasu poniżej Twojej minimalnej pewności.}}",
+    "changeSettings": "Zmień",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Pojemność",
+    "drainRefill": "Spuszczenie i napełnienie",
+    "interval": "Wymiana co",
+    "copySpec": "Kopiuj {spec}",
+    "copied": "Skopiowano",
+    "noFluids": "Katalog nie podaje płynów dla tego silnika.",
+    "addAsPart": "Dodaj jako część",
+    "addFluidAsPart": "Dodaj {system} jako część",
+    "partAdded": "Dodano",
+    "fromStock": "Z magazynu",
+    "addWithoutStock": "Dodaj bez pozycji z magazynu",
+    "takesDrainRefill": "Przy spuszczeniu i uzupełnieniu: {amount}",
+    "takesCapacity": "Łącznie: {amount}",
+    "attribution": "Dane z {source}",
+    "market": "Rynek: {region}",
+    "about": "Na ile to pewne?",
+    "aboutTitle": "Pewność według źródła",
+    "estimateNote": "Wartości oznaczone jako Szacunek są generowane przez AI i nie zostały sprawdzone z danymi producenta. Sprawdź je przed przygotowaniem wyceny dla klienta.",
+    "lockedNote": "Ten klucz pokazuje wartości pojedynczo, a każda liczy się jako jedno zapytanie. Płatny plan pokazuje wszystkie naraz.",
+    "settings": "Ustawienia norm czasowych",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Porównane z danymi producenta",
+      "high": "Zweryfikowane",
+      "highHelp": "Zweryfikowane przez źródło",
+      "medium": "Średnia",
+      "mediumHelp": "Źródło ocenia tę wartość jako średnio pewną",
+      "estimated": "Szacunek",
+      "estimatedHelp": "Wygenerowane przez AI i niesprawdzone z danymi producenta"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Mechanik dodał pozycję robocizny do tego zlecenia} few {Mechanik dodał # pozycje robocizny do tego zlecenia} many {Mechanik dodał # pozycji robocizny do tego zlecenia} other {Mechanik dodał # pozycji robocizny do tego zlecenia}}",
     "body": "Pozostaje poza listą podczas edycji. Następny zapis i tak ją uwzględni.",

+ 43 - 0
messages/pt-BR/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automação",
     "storage": "Arquivos",
     "registry": "Registro de veículos",
+    "repair": "Dados de reparo",
     "other": "Outros",
     "ai": "IA"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Destino de backup"
+    },
+    "repair": {
+      "labor": "Tempos de mão de obra em ordens de serviço e orçamentos",
+      "fluids": "Tipos e capacidades de fluidos"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Uma vez por dia, os modelos não atualizados na última semana são buscados de novo, para que a página do veículo nunca espere e um novo recall apareça em até uma semana. Só marca, modelo e ano são enviados."
       }
     },
+    "openlaborproject": {
+      "description": "Consulte os tempos de mão de obra e as capacidades de fluidos do veículo de uma ordem de serviço ou orçamento, e adicione as horas de um serviço como linha de mão de obra com um clique. A maior parte dos dados é de veículos do mercado americano, e a tabela mostra a qual mercado cada consulta se refere. Cada valor mostra o quanto a Open Labor Project tem certeza dele, e muitos são estimativas geradas por IA, então confira antes de passar um orçamento ao cliente. As consultas usam a chave de API da própria oficina. Com um plano pago da Open Labor Project, um veículo consultado uma vez fica guardado enquanto o plano durar, então consultá-lo de novo não gasta nenhuma chamada.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Solicite uma chave em openlaborproject.com, em Developers; ela chega por e-mail. Uma chave gratuita tem 10 consultas por dia e mostra as horas de um serviço por vez. O plano Builder e superiores aumentam o limite e mostram as horas de todos os serviços de uma vez.",
+        "apiKeyServerHelp": "Deixe em branco para usar a chave fornecida por este servidor, ou cole sua própria chave do openlaborproject.com para usar seu próprio plano."
+      },
+      "settings": {
+        "minConfidence": "Mostrar tempos a partir de",
+        "minConfidenceHelp": "Deixa de fora os tempos de que a fonte tem menos certeza. Muitos tempos de marcas europeias são estimativas por IA, então uma escolha rígida pode deixar poucos serviços.",
+        "confidenceAll": "Qualquer nível de confiança",
+        "confidenceMedium": "Média ou superior",
+        "confidenceHigh": "Verificado ou superior",
+        "confidenceOem": "Somente OEM",
+        "hoursFrom": "Horas a adicionar",
+        "hoursFromHelp": "Qual número vai para a ordem de serviço: o tempo da tabela, ou o topo da faixa que a Open Labor Project informa (por exemplo, 1,6 h em vez de 1,2 h). O topo da faixa deixa folga para serviços que demoram mais.",
+        "hoursBook": "Tempo da tabela",
+        "hoursHigh": "Topo da faixa",
+        "adjustPercent": "Ajustar tempos em (%)",
+        "adjustPercentHelp": "Uma porcentagem somada a cada tempo antes de ir para a ordem de serviço: 10 transforma 1,2 h em 1,32 h, e -10 em 1,08 h. De -50 a 200.",
+        "rounding": "Arredondar horas para cima em",
+        "roundingHelp": "Arredonda para cima as horas de cada serviço, depois de qualquer ajuste: com 0,25, 1,32 h vira 1,5 h. Um tempo que já está em um passo continua igual.",
+        "roundNone": "Não arredondar",
+        "roundTenth": "0,1 hora",
+        "roundQuarter": "0,25 hora",
+        "roundHalf": "0,5 hora",
+        "fluids": "Mostrar especificações de fluidos",
+        "fluidsHelp": "Adiciona uma aba Fluidos com capacidades, especificações e intervalos de troca. Abri-la usa uma chamada própria.",
+        "units": "Unidades de fluidos",
+        "unitsHelp": "As unidades em que capacidades e intervalos de troca são mostrados.",
+        "unitsBoth": "Ambas",
+        "unitsMetric": "Métricas (L, km)",
+        "unitsUs": "EUA (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Voz para texto",
       "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É separado do seu provedor de IA, então você pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor próprio que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Atualização dos dados de recall",
       "vehicle": "Verificação de recall para um veículo novo"
+    },
+    "repair": {
+      "checkPlan": "Verificar se as consultas guardadas podem ser mantidas"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/pt-BR/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "A taxa de oficina das Configurações: {percent} % da mão de obra deste serviço. Exclua a linha para removê-la.",
     "shopFeeHintPercentAll": "A taxa de oficina das Configurações: {percent} % da mão de obra e das peças deste serviço. Exclua a linha para removê-la."
   },
+  "laborGuide": {
+    "button": "Tabela de tempos",
+    "title": "Tabela de tempos",
+    "description": "Tempos de mão de obra e fluidos deste veículo",
+    "tabLabor": "Tempos de mão de obra",
+    "tabFluids": "Fluidos",
+    "engine": "Motor",
+    "categories": "Categorias de serviço",
+    "allJobs": "Todos os serviços",
+    "search": "Buscar serviços, como pastilhas de freio",
+    "clearSearch": "Limpar busca",
+    "jobCount": "{count, plural, one {# serviço} other {# serviços}}",
+    "noMatch": "Nenhum serviço corresponde a “{query}”",
+    "noJobs": "A tabela não lista serviços para este motor.",
+    "showAll": "Mostrar todos os serviços",
+    "showHours": "Ver horas",
+    "show": "Ver",
+    "lockedValue": "Não exibido com esta chave",
+    "hours": "{hours} h",
+    "range": "{low} a {high} h",
+    "bookTime": "Tabela {hours} h",
+    "add": "Adicionar como linha de mão de obra",
+    "added": "Adicionado",
+    "addJob": "Adicionar {job} como linha de mão de obra",
+    "addedJob": "{job} foi adicionado",
+    "addedCount": "{count, plural, one {# serviço adicionado} other {# serviços adicionados}}",
+    "partsAddedCount": "{count, plural, one {# peça adicionada} other {# peças adicionadas}}",
+    "done": "Concluir",
+    "loading": "Buscando o veículo",
+    "stillLooking": "Ainda buscando. A Open Labor Project pode levar até meio minuto para um veículo sobre o qual tem poucos dados.",
+    "notFoundTitle": "Não está na tabela",
+    "notFound": "A tabela de tempos não tem dados para este veículo. Confira se marca, modelo e ano estão escritos como o fabricante os nomeia, ou adicione o VIN.",
+    "fluidsNotFoundTitle": "Sem especificações de fluidos para este veículo",
+    "fluidsNotFound": "A Open Labor Project ainda não tem especificações de fluidos para este veículo.",
+    "failedTitle": "A consulta não foi concluída",
+    "failed": "A consulta falhou",
+    "unlockFailed": "Não foi possível obter esse valor",
+    "retry": "Tentar novamente",
+    "matchNone": "Nenhum dos motores listados corresponde ao cadastrado para este veículo, então estes tempos podem ser de outro motor. Escolha acima o motor mais próximo.",
+    "matchFuel": "O motor foi escolhido pelo tipo de combustível. Confira se é o correto.",
+    "matchUnknown": "Este veículo não tem cilindrada cadastrada, então confira se o motor acima é o correto.",
+    "hiddenByConfidence": "{count, plural, one {# tempo abaixo da sua confiança mínima está oculto.} other {# tempos abaixo da sua confiança mínima estão ocultos.}}",
+    "changeSettings": "Alterar",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Capacidade",
+    "drainRefill": "Drenagem e reabastecimento",
+    "interval": "Trocar a cada",
+    "copySpec": "Copiar {spec}",
+    "copied": "Copiado",
+    "noFluids": "A tabela não lista fluidos para este motor.",
+    "addAsPart": "Adicionar como peça",
+    "addFluidAsPart": "Adicionar {system} como peça",
+    "partAdded": "Adicionado",
+    "fromStock": "Do estoque",
+    "addWithoutStock": "Adicionar sem item do estoque",
+    "takesDrainRefill": "Leva {amount} ao drenar e reabastecer",
+    "takesCapacity": "Leva {amount} no total",
+    "attribution": "Dados de {source}",
+    "market": "Mercado: {region}",
+    "about": "Qual a confiabilidade?",
+    "aboutTitle": "Confiabilidade segundo a fonte",
+    "estimateNote": "Valores marcados como Estimativa são gerados por IA e não foram conferidos com os dados do fabricante. Confira-os antes de fazer um orçamento para um cliente.",
+    "lockedNote": "Esta chave mostra os valores um de cada vez, e cada um conta como uma consulta. Um plano pago mostra todos de uma vez.",
+    "settings": "Configurações da tabela de tempos",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Conferido com os dados do fabricante",
+      "high": "Verificado",
+      "highHelp": "Verificado pela fonte",
+      "medium": "Média",
+      "mediumHelp": "A fonte classifica este valor com confiança média",
+      "estimated": "Estimativa",
+      "estimatedHelp": "Gerado por IA e não conferido com os dados do fabricante"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta ordem} other {Um técnico adicionou # linhas de mão de obra a esta ordem}}",
     "body": "Ela fica fora da lista enquanto você edita. O próximo salvamento a inclui de qualquer forma.",

+ 43 - 0
messages/pt-PT/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Automação",
     "storage": "Ficheiros",
     "registry": "Registo automóvel",
+    "repair": "Dados de reparação",
     "other": "Outros",
     "ai": "IA"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Destino das cópias de segurança"
+    },
+    "repair": {
+      "labor": "Tempos de mão de obra em ordens de trabalho e orçamentos",
+      "fluids": "Tipos e capacidades de fluidos"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Uma vez por dia, os modelos não atualizados na última semana são obtidos de novo, para que a página da viatura nunca tenha de esperar e um novo recall apareça no prazo de uma semana. Só são enviados a marca, o modelo e o ano."
       }
     },
+    "openlaborproject": {
+      "description": "Consulte os tempos de mão de obra e as capacidades de fluidos do veículo de uma ordem de reparação ou orçamento, e adicione as horas de um trabalho como linha de mão de obra com um clique. A maior parte dos dados é de veículos do mercado norte-americano, e a tabela indica a que mercado cada pesquisa se refere. Cada valor indica a confiança da Open Labor Project, e muitos são estimativas geradas por IA, por isso verifique-os antes de apresentar um orçamento ao cliente. As pesquisas usam a chave de API da própria oficina. Com um plano pago da Open Labor Project, um veículo pesquisado uma vez fica guardado enquanto o plano durar, por isso voltar a pesquisá-lo não gasta nenhuma chamada.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Peça uma chave em openlaborproject.com, em Developers; chega por e-mail. Uma chave gratuita tem 10 pedidos por dia e mostra as horas de um trabalho de cada vez. O plano Builder e superiores aumentam o limite e mostram as horas de todos os trabalhos de uma vez.",
+        "apiKeyServerHelp": "Deixe em branco para usar a chave fornecida por este servidor, ou cole a sua própria chave de openlaborproject.com para usar o seu próprio plano."
+      },
+      "settings": {
+        "minConfidence": "Mostrar tempos a partir de",
+        "minConfidenceHelp": "Exclui os tempos de que a fonte tem menos certeza. Muitos tempos de marcas europeias são estimativas por IA, por isso uma escolha rigorosa pode deixar poucos trabalhos.",
+        "confidenceAll": "Qualquer nível de confiança",
+        "confidenceMedium": "Média ou superior",
+        "confidenceHigh": "Verificado ou superior",
+        "confidenceOem": "Apenas OEM",
+        "hoursFrom": "Horas a adicionar",
+        "hoursFromHelp": "Que valor vai para a ordem de reparação: o tempo de tabela, ou o topo do intervalo indicado pela Open Labor Project (por exemplo, 1,6 h em vez de 1,2 h). O topo do intervalo deixa margem para trabalhos que demoram mais.",
+        "hoursBook": "Tempo de tabela",
+        "hoursHigh": "Topo do intervalo",
+        "adjustPercent": "Ajustar tempos em (%)",
+        "adjustPercentHelp": "Uma percentagem somada a cada tempo antes de passar para a ordem de reparação: 10 transforma 1,2 h em 1,32 h, e -10 em 1,08 h. De -50 a 200.",
+        "rounding": "Arredondar horas por excesso a",
+        "roundingHelp": "Arredonda por excesso as horas de cada trabalho, depois de qualquer ajuste: com 0,25, 1,32 h passa a 1,5 h. Um tempo que já está num passo fica como está.",
+        "roundNone": "Não arredondar",
+        "roundTenth": "0,1 hora",
+        "roundQuarter": "0,25 hora",
+        "roundHalf": "0,5 hora",
+        "fluids": "Mostrar especificações de fluidos",
+        "fluidsHelp": "Acrescenta um separador Fluidos com capacidades, especificações e intervalos de substituição. Abri-lo usa uma chamada própria.",
+        "units": "Unidades de fluidos",
+        "unitsHelp": "As unidades em que se apresentam capacidades e intervalos de substituição.",
+        "unitsBoth": "Ambas",
+        "unitsMetric": "Métricas (L, km)",
+        "unitsUs": "EUA (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Voz para texto",
       "description": "Ditado na ordem de serviço, transcrito por um modelo de voz em vez do navegador: muito mais preciso, em qualquer idioma, e conhece o vocabulário de oficina. É independente do seu fornecedor de IA, por isso pode manter esse como está. Usa a sua própria chave da OpenAI ou um servidor seu que fale a API de transcrição da OpenAI. A gravação é enviada para ser transcrita e não é guardada.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Atualização dos dados de recall",
       "vehicle": "Verificação de recall para uma viatura nova"
+    },
+    "repair": {
+      "checkPlan": "Verificar se as pesquisas guardadas podem ser mantidas"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/pt-PT/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "A taxa de oficina das Definições: {percent} % da mão de obra deste trabalho. Elimine a linha para a retirar.",
     "shopFeeHintPercentAll": "A taxa de oficina das Definições: {percent} % da mão de obra e das peças deste trabalho. Elimine a linha para a retirar."
   },
+  "laborGuide": {
+    "button": "Tabela de tempos",
+    "title": "Tabela de tempos",
+    "description": "Tempos de mão de obra e fluidos deste veículo",
+    "tabLabor": "Tempos de mão de obra",
+    "tabFluids": "Fluidos",
+    "engine": "Motor",
+    "categories": "Categorias de trabalho",
+    "allJobs": "Todos os trabalhos",
+    "search": "Procurar trabalhos, como pastilhas de travão",
+    "clearSearch": "Limpar pesquisa",
+    "jobCount": "{count, plural, one {# trabalho} other {# trabalhos}}",
+    "noMatch": "Nenhum trabalho corresponde a «{query}»",
+    "noJobs": "A tabela não lista trabalhos para este motor.",
+    "showAll": "Mostrar todos os trabalhos",
+    "showHours": "Ver horas",
+    "show": "Ver",
+    "lockedValue": "Não apresentado com esta chave",
+    "hours": "{hours} h",
+    "range": "{low} a {high} h",
+    "bookTime": "Tabela {hours} h",
+    "add": "Adicionar como linha de mão de obra",
+    "added": "Adicionado",
+    "addJob": "Adicionar {job} como linha de mão de obra",
+    "addedJob": "{job} foi adicionado",
+    "addedCount": "{count, plural, one {# trabalho adicionado} other {# trabalhos adicionados}}",
+    "partsAddedCount": "{count, plural, one {# peça adicionada} other {# peças adicionadas}}",
+    "done": "Concluir",
+    "loading": "A procurar o veículo",
+    "stillLooking": "Ainda a pesquisar. A Open Labor Project pode demorar até meio minuto para um veículo sobre o qual tem poucos dados.",
+    "notFoundTitle": "Não consta da tabela",
+    "notFound": "A tabela de tempos não tem dados para este veículo. Verifique se a marca, o modelo e o ano estão escritos como o fabricante os designa, ou adicione o VIN.",
+    "fluidsNotFoundTitle": "Sem especificações de fluidos para este veículo",
+    "fluidsNotFound": "A Open Labor Project ainda não tem especificações de fluidos para este veículo.",
+    "failedTitle": "A pesquisa não foi concluída",
+    "failed": "A pesquisa falhou",
+    "unlockFailed": "Não foi possível obter esse valor",
+    "retry": "Tentar novamente",
+    "matchNone": "Nenhum dos motores listados corresponde ao registado para este veículo, por isso estes tempos podem ser de outro motor. Escolha acima o motor mais próximo.",
+    "matchFuel": "O motor foi escolhido pelo tipo de combustível. Verifique se é o correto.",
+    "matchUnknown": "Este veículo não tem cilindrada registada, por isso verifique se o motor acima é o correto.",
+    "hiddenByConfidence": "{count, plural, one {# tempo abaixo da sua confiança mínima está oculto.} other {# tempos abaixo da sua confiança mínima estão ocultos.}}",
+    "changeSettings": "Alterar",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Capacidade",
+    "drainRefill": "Escoamento e enchimento",
+    "interval": "Substituir a cada",
+    "copySpec": "Copiar {spec}",
+    "copied": "Copiado",
+    "noFluids": "A tabela não lista fluidos para este motor.",
+    "addAsPart": "Adicionar como peça",
+    "addFluidAsPart": "Adicionar {system} como peça",
+    "partAdded": "Adicionado",
+    "fromStock": "Do stock",
+    "addWithoutStock": "Adicionar sem artigo do stock",
+    "takesDrainRefill": "Leva {amount} ao drenar e reabastecer",
+    "takesCapacity": "Leva {amount} no total",
+    "attribution": "Dados de {source}",
+    "market": "Mercado: {region}",
+    "about": "Qual a fiabilidade?",
+    "aboutTitle": "Fiabilidade segundo a fonte",
+    "estimateNote": "Os valores marcados como Estimativa são gerados por IA e não foram verificados com os dados do fabricante. Verifique-os antes de orçamentar a um cliente.",
+    "lockedNote": "Esta chave mostra os valores um de cada vez, e cada um conta como um pedido. Um plano pago mostra-os todos de uma vez.",
+    "settings": "Definições da tabela de tempos",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Comparado com os dados do fabricante",
+      "high": "Verificado",
+      "highHelp": "Verificado pela fonte",
+      "medium": "Média",
+      "mediumHelp": "A fonte classifica este valor com confiança média",
+      "estimated": "Estimativa",
+      "estimatedHelp": "Gerado por IA e não verificado com os dados do fabricante"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Um técnico adicionou mão de obra a esta folha de obra} other {Um técnico adicionou # linhas de mão de obra a esta folha de obra}}",
     "body": "Fica fora da lista enquanto edita. A próxima gravação inclui-a de qualquer forma.",

+ 43 - 0
messages/ru/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Автоматизация",
     "storage": "Файлы",
     "registry": "Реестр транспортных средств",
+    "repair": "Данные для ремонта",
     "other": "Другое",
     "ai": "ИИ"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Место назначения резервных копий"
+    },
+    "repair": {
+      "labor": "Нормы времени в заказ-нарядах и сметах",
+      "fluids": "Типы и объёмы жидкостей"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Раз в день модели, не обновлявшиеся за последнюю неделю, запрашиваются заново, чтобы страница автомобиля никогда не ждала, а новая отзывная кампания появлялась в течение недели. Отправляются только марка, модель и год."
       }
     },
+    "openlaborproject": {
+      "description": "Находите нормы времени и объёмы жидкостей для автомобиля из заказ-наряда или сметы и добавляйте часы работы строкой работ одним щелчком. Большая часть данных относится к автомобилям американского рынка, а справочник показывает, к какому рынку относится каждый запрос. Для каждого значения указано, насколько Open Labor Project в нём уверен, и многие из них оценки ИИ, поэтому проверьте их, прежде чем называть клиенту цену. Запросы используют собственный API-ключ мастерской. На платном тарифе Open Labor Project найденный однажды автомобиль хранится, пока действует тариф, поэтому повторный поиск не расходует запрос.",
+      "fields": {
+        "apiKey": "API-ключ",
+        "apiKeyHelp": "Запросите ключ на openlaborproject.com в разделе Developers; он придёт по электронной почте. Бесплатный ключ даёт 10 запросов в день и показывает часы по одной работе. Тариф Builder и выше повышает лимит и показывает часы всех работ сразу.",
+        "apiKeyServerHelp": "Оставьте поле пустым, чтобы использовать ключ этого сервера, или вставьте свой ключ с openlaborproject.com, чтобы использовать собственный тариф."
+      },
+      "settings": {
+        "minConfidence": "Показывать нормы начиная с",
+        "minConfidenceHelp": "Скрывает нормы, в которых источник менее уверен. Многие нормы для европейских марок оценки ИИ, поэтому строгий выбор может оставить мало работ.",
+        "confidenceAll": "Любой уровень надёжности",
+        "confidenceMedium": "Средняя и выше",
+        "confidenceHigh": "Проверено и выше",
+        "confidenceOem": "Только OEM",
+        "hoursFrom": "Добавляемые часы",
+        "hoursFromHelp": "Какое значение попадает в заказ-наряд: норма времени или верхняя граница диапазона, который даёт Open Labor Project (например, 1,6 ч вместо 1,2 ч). Верхняя граница оставляет запас для работ, которые затягиваются.",
+        "hoursBook": "Норма времени",
+        "hoursHigh": "Верхняя граница диапазона",
+        "adjustPercent": "Изменить нормы на (%)",
+        "adjustPercentHelp": "Процент, который прибавляется к каждой норме перед добавлением в заказ-наряд: 10 превращает 1,2 ч в 1,32 ч, -10 в 1,08 ч. От -50 до 200.",
+        "rounding": "Округлять часы вверх до",
+        "roundingHelp": "Округляет часы каждой работы вверх после изменения: при шаге 0,25 из 1,32 ч получается 1,5 ч. Время, которое уже попадает на шаг, остаётся прежним.",
+        "roundNone": "Не округлять",
+        "roundTenth": "0,1 часа",
+        "roundQuarter": "0,25 часа",
+        "roundHalf": "0,5 часа",
+        "fluids": "Показывать данные о жидкостях",
+        "fluidsHelp": "Добавляет вкладку «Жидкости» с объёмами, спецификациями и интервалами замены. Её открытие расходует отдельный запрос.",
+        "units": "Единицы для жидкостей",
+        "unitsHelp": "Единицы, в которых показаны объёмы и интервалы замены.",
+        "unitsBoth": "Обе",
+        "unitsMetric": "Метрические (L, km)",
+        "unitsUs": "США (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Речь в текст",
       "description": "Диктовка в заказ-наряде, которую записывает речевая модель, а не браузер: гораздо точнее, на любом языке и со словами мастерской. Работает отдельно от вашего провайдера ИИ, так что его можно оставить как есть. Использует ваш собственный ключ OpenAI или ваш собственный сервер с API распознавания речи в стиле OpenAI. Запись отправляется на расшифровку и не сохраняется.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Обновление данных об отзывных кампаниях",
       "vehicle": "Проверка отзывных кампаний для нового автомобиля"
+    },
+    "repair": {
+      "checkPlan": "Проверка, можно ли хранить сохранённые запросы"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/ru/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Сбор мастерской из настроек: {percent} % от работ по этому заказу. Удалите строку, чтобы убрать его.",
     "shopFeeHintPercentAll": "Сбор мастерской из настроек: {percent} % от работ и запчастей по этому заказу. Удалите строку, чтобы убрать его."
   },
+  "laborGuide": {
+    "button": "Нормы времени",
+    "title": "Нормы времени",
+    "description": "Нормы времени и жидкости для этого автомобиля",
+    "tabLabor": "Нормы времени",
+    "tabFluids": "Жидкости",
+    "engine": "Двигатель",
+    "categories": "Категории работ",
+    "allJobs": "Все работы",
+    "search": "Поиск работ, например тормозные колодки",
+    "clearSearch": "Очистить поиск",
+    "jobCount": "{count, plural, one {# работа} few {# работы} many {# работ} other {# работы}}",
+    "noMatch": "Нет работ по запросу «{query}»",
+    "noJobs": "В справочнике нет работ для этого двигателя.",
+    "showAll": "Показать все работы",
+    "showHours": "Показать часы",
+    "show": "Показать",
+    "lockedValue": "Не показано для этого ключа",
+    "hours": "{hours} ч",
+    "range": "от {low} до {high} ч",
+    "bookTime": "Норма {hours} ч",
+    "add": "Добавить строкой работ",
+    "added": "Добавлено",
+    "addJob": "Добавить {job} как строку работ",
+    "addedJob": "{job}: добавлено",
+    "addedCount": "{count, plural, one {Добавлена # работа} few {Добавлены # работы} many {Добавлено # работ} other {Добавлено # работы}}",
+    "partsAddedCount": "{count, plural, one {Добавлена # запчасть} few {Добавлены # запчасти} many {Добавлено # запчастей} other {Добавлено # запчасти}}",
+    "done": "Готово",
+    "loading": "Поиск автомобиля",
+    "stillLooking": "Поиск продолжается. Для автомобиля, о котором мало данных, Open Labor Project может отвечать до полуминуты.",
+    "notFoundTitle": "Нет в справочнике",
+    "notFound": "В справочнике норм нет данных для этого автомобиля. Проверьте, что марка, модель и год указаны так, как их называет производитель, или добавьте VIN.",
+    "fluidsNotFoundTitle": "Нет данных о жидкостях для этого автомобиля",
+    "fluidsNotFound": "У Open Labor Project пока нет данных о жидкостях для этого автомобиля.",
+    "failedTitle": "Запрос не выполнен",
+    "failed": "Не удалось выполнить поиск",
+    "unlockFailed": "Не удалось получить это значение",
+    "retry": "Повторить",
+    "matchNone": "Ни один из перечисленных двигателей не совпадает с указанным для этого автомобиля, поэтому нормы могут относиться к другому двигателю. Выберите выше наиболее близкий двигатель.",
+    "matchFuel": "Двигатель выбран по типу топлива. Проверьте, что он правильный.",
+    "matchUnknown": "Для этого автомобиля не указан объём двигателя, поэтому проверьте, что двигатель выше выбран правильно.",
+    "hiddenByConfidence": "{count, plural, one {Скрыта # норма ниже вашей минимальной надёжности.} few {Скрыты # нормы ниже вашей минимальной надёжности.} many {Скрыто # норм ниже вашей минимальной надёжности.} other {Скрыто # нормы ниже вашей минимальной надёжности.}}",
+    "changeSettings": "Изменить",
+    "liters": "{value} л",
+    "quarts": "{value} кварт",
+    "km": "{value} км",
+    "miles": "{value} миль",
+    "capacity": "Объём",
+    "drainRefill": "Слив и заливка",
+    "interval": "Замена каждые",
+    "copySpec": "Копировать {spec}",
+    "copied": "Скопировано",
+    "noFluids": "В справочнике нет жидкостей для этого двигателя.",
+    "addAsPart": "Добавить как запчасть",
+    "addFluidAsPart": "Добавить {system} как запчасть",
+    "partAdded": "Добавлено",
+    "fromStock": "Со склада",
+    "addWithoutStock": "Добавить без позиции со склада",
+    "takesDrainRefill": "При сливе и заливке: {amount}",
+    "takesCapacity": "Всего: {amount}",
+    "attribution": "Данные: {source}",
+    "market": "Рынок: {region}",
+    "about": "Насколько это надёжно?",
+    "aboutTitle": "Надёжность по оценке источника",
+    "estimateNote": "Значения с пометкой «Оценка» созданы ИИ и не сверены с данными производителя. Проверьте их, прежде чем называть цену клиенту.",
+    "lockedNote": "Этот ключ показывает значения по одному, и каждое считается запросом. Платный тариф показывает все сразу.",
+    "settings": "Настройки норм времени",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Сверено с данными производителя",
+      "high": "Проверено",
+      "highHelp": "Проверено источником",
+      "medium": "Средняя",
+      "mediumHelp": "Источник оценивает надёжность этого значения как среднюю",
+      "estimated": "Оценка",
+      "estimatedHelp": "Создано ИИ и не сверено с данными производителя"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Механик добавил # строку работ в этот заказ} few {Механик добавил # строки работ в этот заказ} many {Механик добавил # строк работ в этот заказ} other {Механик добавил # строк работ в этот заказ}}",
     "body": "Пока вы редактируете, она не попадает в список. При следующем сохранении она сохранится в любом случае.",

+ 43 - 0
messages/tr/integrations.json

@@ -30,6 +30,7 @@
     "automation": "Otomasyon",
     "storage": "Dosyalar",
     "registry": "Araç kayıt sistemi",
+    "repair": "Onarım verileri",
     "other": "Diğer",
     "ai": "Yapay zekâ"
   },
@@ -73,6 +74,10 @@
     },
     "backup": {
       "target": "Yedekleme hedefi"
+    },
+    "repair": {
+      "labor": "İş emirlerinde ve tekliflerde işçilik süreleri",
+      "fluids": "Sıvı türleri ve kapasiteleri"
     }
   },
   "statuses": {
@@ -777,6 +782,41 @@
         "refreshFleetHelp": "Günde bir kez, son bir haftada yenilenmemiş modeller yeniden çekilir; böylece araç sayfası hiç beklemez ve yeni bir geri çağırma bir hafta içinde görünür. Yalnızca marka, model ve yıl gönderilir."
       }
     },
+    "openlaborproject": {
+      "description": "İş emri veya teklifteki aracın işçilik sürelerini ve sıvı kapasitelerini sorgulayın, bir işin saatlerini tek tıkla işçilik satırı olarak ekleyin. Verilerin çoğu ABD pazarı araçları içindir ve rehber her sorgunun hangi pazar için olduğunu gösterir. Her değer Open Labor Project’in ondan ne kadar emin olduğunu gösterir ve birçoğu yapay zekâ tahminidir; müşteriye teklif vermeden önce kontrol edin. Sorgular atölyenin kendi API anahtarını kullanır. Ücretli bir Open Labor Project planında bir kez sorgulanan araç plan sürdükçe saklanır, böylece yeniden sorgulamak çağrı harcamaz.",
+      "fields": {
+        "apiKey": "API anahtarı",
+        "apiKeyHelp": "openlaborproject.com adresinde Developers bölümünden anahtar isteyin; e-postayla gelir. Ücretsiz anahtarın günde 10 sorgu hakkı vardır ve saatleri her seferinde tek bir iş için gösterir. Builder planı ve üstü limiti artırır ve tüm işlerin saatlerini bir kerede gösterir.",
+        "apiKeyServerHelp": "Bu sunucunun sağladığı anahtarı kullanmak için boş bırakın ya da kendi planınızı kullanmak için openlaborproject.com’dan aldığınız anahtarı yapıştırın."
+      },
+      "settings": {
+        "minConfidence": "Gösterilecek süreler",
+        "minConfidenceHelp": "Kaynağın daha az emin olduğu süreleri dışarıda bırakır. Avrupa markaları için birçok süre yapay zekâ tahminidir, bu yüzden katı bir seçim az iş bırakabilir.",
+        "confidenceAll": "Her güven düzeyi",
+        "confidenceMedium": "Orta ve üstü",
+        "confidenceHigh": "Doğrulanmış ve üstü",
+        "confidenceOem": "Yalnızca OEM",
+        "hoursFrom": "Eklenecek saat",
+        "hoursFromHelp": "İş emrine hangi değerin gireceği: rehber süresi ya da Open Labor Project’in verdiği aralığın üst sınırı (örneğin 1,2 sa yerine 1,6 sa). Üst sınır, uzayan işler için pay bırakır.",
+        "hoursBook": "Rehber süresi",
+        "hoursHigh": "Aralığın üst sınırı",
+        "adjustPercent": "Süreleri ayarla (%)",
+        "adjustPercentHelp": "İş emrine girmeden önce her süreye eklenen bir yüzde: 10, 1,2 sa’yi 1,32 sa yapar, -10 ise 1,08 sa yapar. -50 ile 200 arası.",
+        "rounding": "Saatleri yukarı yuvarla",
+        "roundingHelp": "Her işin saatlerini, varsa ayardan sonra yukarı yuvarlar: 0,25 ile 1,32 sa, 1,5 sa olur. Zaten bir adımın üzerindeki süre olduğu gibi kalır.",
+        "roundNone": "Yuvarlama",
+        "roundTenth": "0,1 saat",
+        "roundQuarter": "0,25 saat",
+        "roundHalf": "0,5 saat",
+        "fluids": "Sıvı bilgilerini göster",
+        "fluidsHelp": "Kapasiteler, özellikler ve değişim aralıklarıyla bir Sıvılar sekmesi ekler. Açmak ayrı bir çağrı harcar.",
+        "units": "Sıvı birimleri",
+        "unitsHelp": "Kapasitelerin ve değişim aralıklarının gösterildiği birimler.",
+        "unitsBoth": "İkisi de",
+        "unitsMetric": "Metrik (L, km)",
+        "unitsUs": "ABD (qt, mi)"
+      }
+    },
     "speech-to-text": {
       "name": "Konuşmadan metne",
       "description": "İş emrindeki dikte, tarayıcı yerine bir konuşma modeli tarafından yazıya dökülür: çok daha doğrudur, her dilde çalışır ve atölye terimlerini bilir. Yapay zekâ sağlayıcınızdan ayrıdır, o yüzden onu olduğu gibi bırakabilirsiniz. Kendi OpenAI anahtarınızı ya da OpenAI'nin transkripsiyon API'siyle çalışan kendi sunucunuzu kullanır. Kayıt yazıya dökülmek üzere gönderilir ve saklanmaz.",
@@ -918,6 +958,9 @@
     "safety": {
       "refresh": "Geri çağırma verisi yenileme",
       "vehicle": "Yeni araç için geri çağırma kontrolü"
+    },
+    "repair": {
+      "checkPlan": "Saklanan sorguların tutulabileceğini kontrol et"
     }
   },
   "backupAlerts": {

+ 78 - 0
messages/tr/service.json

@@ -175,6 +175,84 @@
     "shopFeeHintPercentLabor": "Ayarlar'daki atölye ücreti: bu işteki işçiliğin % {percent} kadarı. Kaldırmak için satırı silin.",
     "shopFeeHintPercentAll": "Ayarlar'daki atölye ücreti: bu işteki işçilik ve parçaların % {percent} kadarı. Kaldırmak için satırı silin."
   },
+  "laborGuide": {
+    "button": "İşçilik süreleri",
+    "title": "İşçilik süreleri",
+    "description": "Bu araç için işçilik süreleri ve sıvılar",
+    "tabLabor": "İşçilik süreleri",
+    "tabFluids": "Sıvılar",
+    "engine": "Motor",
+    "categories": "İş kategorileri",
+    "allJobs": "Tüm işler",
+    "search": "İş ara, örneğin fren balatası",
+    "clearSearch": "Aramayı temizle",
+    "jobCount": "{count, plural, one {# iş} other {# iş}}",
+    "noMatch": "“{query}” ile eşleşen iş yok",
+    "noJobs": "Rehberde bu motor için iş yok.",
+    "showAll": "Tüm işleri göster",
+    "showHours": "Saatleri göster",
+    "show": "Göster",
+    "lockedValue": "Bu anahtarla gösterilmiyor",
+    "hours": "{hours} sa",
+    "range": "{low} ile {high} sa arası",
+    "bookTime": "Rehber süresi {hours} sa",
+    "add": "İşçilik satırı olarak ekle",
+    "added": "Eklendi",
+    "addJob": "{job} işini işçilik satırı olarak ekle",
+    "addedJob": "{job} eklendi",
+    "addedCount": "{count, plural, one {# iş eklendi} other {# iş eklendi}}",
+    "partsAddedCount": "{count, plural, one {# parça eklendi} other {# parça eklendi}}",
+    "done": "Bitti",
+    "loading": "Araç aranıyor",
+    "stillLooking": "Hâlâ aranıyor. Az veri bulunan bir araç için Open Labor Project’in yanıtı yarım dakikayı bulabilir.",
+    "notFoundTitle": "Rehberde yok",
+    "notFound": "İşçilik rehberinde bu araç için veri yok. Marka, model ve yılın üreticinin adlandırdığı şekilde yazıldığını kontrol edin veya VIN ekleyin.",
+    "fluidsNotFoundTitle": "Bu araç için sıvı bilgisi yok",
+    "fluidsNotFound": "Open Labor Project’te bu araç için henüz sıvı bilgisi yok.",
+    "failedTitle": "Sorgu tamamlanamadı",
+    "failed": "Sorgu başarısız oldu",
+    "unlockFailed": "Bu değer alınamadı",
+    "retry": "Tekrar dene",
+    "matchNone": "Listelenen motorların hiçbiri bu araç için kayıtlı motorla eşleşmiyor, bu yüzden süreler başka bir motora ait olabilir. Yukarıdan en yakın motoru seçin.",
+    "matchFuel": "Motor yakıt türüne göre seçildi. Doğru motor olduğunu kontrol edin.",
+    "matchUnknown": "Bu araç için motor hacmi kayıtlı değil, bu yüzden yukarıdaki motorun doğru olduğunu kontrol edin.",
+    "hiddenByConfidence": "{count, plural, one {Asgari güveninizin altındaki # süre gizlendi.} other {Asgari güveninizin altındaki # süre gizlendi.}}",
+    "changeSettings": "Değiştir",
+    "liters": "{value} L",
+    "quarts": "{value} qt",
+    "km": "{value} km",
+    "miles": "{value} mi",
+    "capacity": "Kapasite",
+    "drainRefill": "Boşaltma ve doldurma",
+    "interval": "Değişim aralığı",
+    "copySpec": "{spec} kopyala",
+    "copied": "Kopyalandı",
+    "noFluids": "Rehberde bu motor için sıvı yok.",
+    "addAsPart": "Parça olarak ekle",
+    "addFluidAsPart": "{system} parça olarak ekle",
+    "partAdded": "Eklendi",
+    "fromStock": "Stoktan",
+    "addWithoutStock": "Stok kalemi olmadan ekle",
+    "takesDrainRefill": "Boşaltıp doldurmada {amount} alır",
+    "takesCapacity": "Toplam {amount} alır",
+    "attribution": "Veriler: {source}",
+    "market": "Pazar: {region}",
+    "about": "Ne kadar güvenilir?",
+    "aboutTitle": "Kaynağa göre güvenilirlik",
+    "estimateNote": "Tahmin olarak işaretlenen değerler yapay zekâ ile üretilmiştir ve üretici verileriyle karşılaştırılmamıştır. Müşteriye fiyat vermeden önce kontrol edin.",
+    "lockedNote": "Bu anahtar değerleri tek tek gösterir ve her biri bir sorgu sayılır. Ücretli bir plan hepsini aynı anda gösterir.",
+    "settings": "İşçilik süreleri ayarları",
+    "confidence": {
+      "oem": "OEM",
+      "oemHelp": "Üreticinin kendi verileriyle karşılaştırıldı",
+      "high": "Doğrulandı",
+      "highHelp": "Kaynak tarafından doğrulandı",
+      "medium": "Orta",
+      "mediumHelp": "Kaynak bu değerin güvenilirliğini orta olarak değerlendiriyor",
+      "estimated": "Tahmin",
+      "estimatedHelp": "Yapay zekâ ile üretildi, üretici verileriyle karşılaştırılmadı"
+    }
+  },
   "liveLabor": {
     "title": "{count, plural, one {Bir teknisyen bu işe işçilik ekledi} other {Bir teknisyen bu işe # işçilik satırı ekledi}}",
     "body": "Siz düzenlerken listeye eklenmez. Bir sonraki kaydetmede yine de kaydedilir.",

+ 31 - 0
prisma/migrations/20261103120000_repair_data_cache/migration.sql

@@ -0,0 +1,31 @@
+-- Labor times and fluid specs from a repair data source (Open Labor
+-- Project), kept for as long as the key that fetched them is on a paid
+-- plan, so a vehicle looked up once is not paid for again. Rows fetched with
+-- a workshop's own key belong to its connection and go when it disconnects;
+-- rows fetched with a key the server holds have no connection and owner
+-- "platform". New table only; nothing existing changes.
+
+-- CreateTable
+CREATE TABLE "repair_data_cache" (
+    "id" TEXT NOT NULL,
+    "source" TEXT NOT NULL,
+    "owner" TEXT NOT NULL,
+    "connectionId" TEXT,
+    "kind" TEXT NOT NULL,
+    "lookupKey" TEXT NOT NULL,
+    "version" INTEGER NOT NULL,
+    "data" JSONB NOT NULL,
+    "fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "repair_data_cache_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "repair_data_cache_source_owner_kind_lookupKey_key" ON "repair_data_cache"("source", "owner", "kind", "lookupKey");
+
+-- CreateIndex
+CREATE INDEX "repair_data_cache_connectionId_idx" ON "repair_data_cache"("connectionId");
+
+-- AddForeignKey
+ALTER TABLE "repair_data_cache" ADD CONSTRAINT "repair_data_cache_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "integration_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 33 - 0
prisma/schema/integrations.prisma

@@ -32,6 +32,7 @@ model IntegrationConnection {
   logs           IntegrationLog[]
   externalEvents ExternalCalendarEvent[]
   backupRuns     BackupRun[]
+  repairData     RepairDataCache[]
 
   @@unique([organizationId, connectorId])
   @@index([organizationId])
@@ -158,6 +159,38 @@ model VehicleSafetyReport {
   @@map("vehicle_safety_reports")
 }
 
+/// Labor times or fluid specs a repair data source answered for one model
+/// year, kept for as long as the key that fetched them is on a plan whose
+/// terms allow it (Open Labor Project's paid plans), so a vehicle looked up
+/// once is not paid for again. Deleted when the key's plan drops to free, the
+/// key is refused, or the workshop disconnects.
+model RepairDataCache {
+  id           String                 @id @default(cuid())
+  /// Connector id, such as "openlaborproject".
+  source       String
+  /// Whose key fetched it: the connection id for a workshop's own key, or
+  /// "platform" for the key the server holds, whose rows serve every
+  /// workshop that uses it.
+  owner        String
+  /// Set for a workshop's own key, so its rows go when it disconnects.
+  connectionId String?
+  connection   IntegrationConnection? @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+  /// labor | fluids
+  kind         String
+  /// "make|model|year" as the workshop wrote them, lower-cased, or
+  /// "vin:<sha-256>" when only the VIN found the vehicle. No VIN is stored.
+  lookupKey    String
+  /// Bumped when the stored shape changes; an older row is fetched again.
+  version      Int
+  data         Json
+  fetchedAt    DateTime               @default(now())
+  createdAt    DateTime               @default(now())
+
+  @@unique([source, owner, kind, lookupKey])
+  @@index([connectionId])
+  @@map("repair_data_cache")
+}
+
 /// One backup of one workshop to one destination: a scheduled or manual
 /// restic run, what it produced and how it ended. Kept so the connection page
 /// can show the last good backup and the cron can tell when one is overdue.

+ 21 - 0
public/images/integrations/openlaborproject.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Open Labor Project">
+  <title>Open Labor Project</title>
+  <defs>
+    <clipPath id="olp-tile"><rect width="180" height="180" rx="41"/></clipPath>
+  </defs>
+  <g clip-path="url(#olp-tile)">
+    <rect width="180" height="180" fill="#8FC3E8"/>
+    <path d="M112 0h35L67 180H32z" fill="#F37021"/>
+  </g>
+  <g fill="none" stroke="#FFFFFF" stroke-width="2.7" stroke-linejoin="round">
+    <circle cx="42.75" cy="87" r="25.5"/>
+    <circle cx="42.75" cy="87" r="21.3"/>
+    <circle cx="42.75" cy="87" r="17.1"/>
+    <circle cx="42.75" cy="87" r="12.9"/>
+    <path d="M77 62v50M81.2 62v50M85.4 62v50M89.6 62v50M89.6 101.6h18.9M89.6 106.1h18.9M89.6 110.6h18.9"/>
+    <path d="M115.2 112V63.2h31.3a17.5 17.5 0 0 1 0 35h-18.3"/>
+    <path d="M119.5 112V67.6h27a13.1 13.1 0 0 1 0 26.2h-18.3"/>
+    <path d="M123.9 112V72h22.6a8.7 8.7 0 0 1 0 17.4h-18.3"/>
+    <path d="M128.2 112V76.3h18.3a4.35 4.35 0 0 1 0 8.7h-18.3"/>
+  </g>
+</svg>

+ 251 - 0
src/__tests__/features/integrations/fixtures/openlaborproject/fluids-model.json

@@ -0,0 +1,251 @@
+{
+  "data": {
+    "make": "Honda",
+    "model": "Civic",
+    "engines": [
+      {
+        "engine": "1.5L I4 Turbo",
+        "engineSlug": "1.5l-i4-turbo",
+        "drivetrain": "fwd",
+        "yearRange": "2020-2020",
+        "fluidSpecs": [
+          {
+            "system": "A/C Refrigerant",
+            "systemType": "ac_refrigerant",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Brake Fluid",
+            "systemType": "brake",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Coolant",
+            "systemType": "coolant",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Engine Oil",
+            "systemType": "engine_oil",
+            "fluidType": "0W-20 full synthetic",
+            "viscosityOptions": ["0W-20"],
+            "fluidSpec": "API SN or higher",
+            "capacityQuarts": 3.7,
+            "capacityLiters": 3.5,
+            "drainRefillQuarts": 3.7,
+            "drainRefillLiters": 3.5,
+            "changeIntervalMiles": 5000,
+            "changeIntervalKm": 8045,
+            "notes": "Capacity includes oil filter replacement",
+            "confidence": "estimated"
+          },
+          {
+            "system": "Power Steering",
+            "systemType": "power_steering",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Automatic Transmission",
+            "systemType": "transmission_auto",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Washer Fluid",
+            "systemType": "washer",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          }
+        ]
+      },
+      {
+        "engine": "2.0L I4",
+        "engineSlug": "2.0l-i4",
+        "drivetrain": "fwd",
+        "yearRange": "2020-2020",
+        "fluidSpecs": [
+          {
+            "system": "A/C Refrigerant",
+            "systemType": "ac_refrigerant",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Brake Fluid",
+            "systemType": "brake",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Coolant",
+            "systemType": "coolant",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Engine Oil",
+            "systemType": "engine_oil",
+            "fluidType": null,
+            "viscosityOptions": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Power Steering",
+            "systemType": "power_steering",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Automatic Transmission",
+            "systemType": "transmission_auto",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "system": "Washer Fluid",
+            "systemType": "washer",
+            "fluidType": null,
+            "fluidSpec": null,
+            "capacityQuarts": null,
+            "capacityLiters": null,
+            "drainRefillQuarts": null,
+            "drainRefillLiters": null,
+            "changeIntervalMiles": null,
+            "changeIntervalKm": null,
+            "notes": null,
+            "confidence": null,
+            "locked": true
+          }
+        ]
+      }
+    ]
+  },
+  "meta": {
+    "engineCount": 2,
+    "totalSpecs": 14,
+    "region": "US",
+    "confidenceCounts": {
+      "high": 2,
+      "estimated": 12
+    },
+    "dataQualityNote": "12 of 14 fluid specs served are unverified (AI-derived from the platform and not yet checked against factory data for this exact vehicle). Check each row's confidence, or pass min_confidence=high to receive only verified data.",
+    "timestamp": "2026-11-02T20:10:39.515Z",
+    "freeTier": {
+      "locked": 13,
+      "vehicle": "2020 Honda Civic"
+    }
+  },
+  "error": null
+}

+ 39 - 0
src/__tests__/features/integrations/fixtures/openlaborproject/fluids-one-system.json

@@ -0,0 +1,39 @@
+{
+  "data": {
+    "vehicle": {
+      "make": "Honda",
+      "model": "Civic",
+      "yearRange": "2020-2020",
+      "engine": "2.0L I4",
+      "fuelType": "gas",
+      "drivetrain": "fwd"
+    },
+    "fluidSpecs": [
+      {
+        "system": "Coolant",
+        "systemType": "coolant",
+        "fluidType": "Honda Type 2 coolant (blue) pre-diluted",
+        "fluidSpec": "Honda Type 2",
+        "capacityQuarts": 5.9,
+        "capacityLiters": 5.58,
+        "drainRefillQuarts": 5.3,
+        "drainRefillLiters": 5.01,
+        "changeIntervalMiles": 120000,
+        "changeIntervalKm": 193080,
+        "notes": "First change at 120k miles, then every 60k miles. Use only Honda Type 2 coolant",
+        "confidence": "estimated"
+      }
+    ]
+  },
+  "meta": {
+    "count": 1,
+    "region": "US",
+    "confidenceCounts": {
+      "estimated": 1
+    },
+    "dataQualityNote": "1 of 1 fluid specs served are unverified (AI-derived from the platform and not yet checked against factory data for this exact vehicle). Check each row's confidence, or pass min_confidence=high to receive only verified data.",
+    "systemQuery": "coolant",
+    "timestamp": "2026-11-02T20:15:35.789Z"
+  },
+  "error": null
+}

+ 194 - 0
src/__tests__/features/integrations/fixtures/openlaborproject/labor-model.json

@@ -0,0 +1,194 @@
+{
+  "data": {
+    "make": "Ford",
+    "model": "F-150",
+    "engines": [
+      {
+        "vehicleId": "7a71f97c-489e-4af0-8c8f-de81d0247928",
+        "engine": "2.7L V6 EcoBoost",
+        "engineSlug": "2.7l-v6-ecoboost",
+        "drivetrain": "rwd",
+        "yearRange": "2018-2018",
+        "laborTimes": [
+          {
+            "job": "100K Service",
+            "jobSlug": "100k-service",
+            "category": "maintenance",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Alternator",
+            "jobSlug": "alternator",
+            "category": "electrical",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Brake Pads and Rotors - Front",
+            "jobSlug": "brake-pads-rotors-front",
+            "category": "brakes",
+            "hours": 2.1,
+            "lowRange": 1.7,
+            "highRange": 2.5,
+            "confidence": "high"
+          },
+          {
+            "job": "Water Pump",
+            "jobSlug": "water-pump",
+            "category": "cooling",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          }
+        ],
+        "engineCode": null,
+        "generation": {
+          "label": "13th gen",
+          "chassisCode": "P552",
+          "yearStart": 2015,
+          "yearEnd": 2020
+        }
+      },
+      {
+        "vehicleId": "0b125774-e115-4844-b2ca-20eb74bcc6fb",
+        "engine": "3.0L V6 Power Stroke Diesel",
+        "engineSlug": "3.0l-v6-diesel",
+        "drivetrain": "rwd",
+        "yearRange": "2018-2018",
+        "laborTimes": [
+          {
+            "job": "100K Service",
+            "jobSlug": "100k-service",
+            "category": "maintenance",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Alternator",
+            "jobSlug": "alternator",
+            "category": "electrical",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Brake Pads and Rotors - Front",
+            "jobSlug": "brake-pads-rotors-front",
+            "category": "brakes",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Water Pump",
+            "jobSlug": "water-pump",
+            "category": "cooling",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          }
+        ],
+        "engineCode": null,
+        "generation": {
+          "label": "13th gen",
+          "chassisCode": "P552",
+          "yearStart": 2015,
+          "yearEnd": 2020
+        }
+      },
+      {
+        "vehicleId": "92a92bb0-7293-4783-97e9-743c15473972",
+        "engine": "5.0L V8 Coyote",
+        "engineSlug": "5.0l-v8-coyote",
+        "drivetrain": "4wd",
+        "yearRange": "2018-2018",
+        "laborTimes": [
+          {
+            "job": "100K Service",
+            "jobSlug": "100k-service",
+            "category": "maintenance",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Alternator",
+            "jobSlug": "alternator",
+            "category": "electrical",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Brake Pads and Rotors - Front",
+            "jobSlug": "brake-pads-rotors-front",
+            "category": "brakes",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          },
+          {
+            "job": "Water Pump",
+            "jobSlug": "water-pump",
+            "category": "cooling",
+            "hours": null,
+            "lowRange": null,
+            "highRange": null,
+            "confidence": null,
+            "locked": true
+          }
+        ],
+        "engineCode": null,
+        "generation": {
+          "label": "13th gen",
+          "chassisCode": "P552",
+          "yearStart": 2015,
+          "yearEnd": 2020
+        }
+      }
+    ]
+  },
+  "meta": {
+    "engineCount": 10,
+    "totalJobs": 4817,
+    "region": "US",
+    "confidenceCounts": {
+      "estimated": 3922,
+      "oem_matched": 20,
+      "high": 257,
+      "medium": 628
+    },
+    "dataQualityNote": "3922 of 4827 labor times served are unverified (AI-derived book times, not matched against an OEM or Mitchell/Motor guide for this exact vehicle). Check each row's confidence, or pass min_confidence=high to receive only verified data.",
+    "timestamp": "2026-11-02T20:10:38.503Z",
+    "freeTier": {
+      "locked": 4816,
+      "hoursLocked": 4816,
+      "vehicle": "2018 Ford F-150"
+    }
+  },
+  "error": null
+}

+ 41 - 0
src/__tests__/features/integrations/fixtures/openlaborproject/labor-one-job.json

@@ -0,0 +1,41 @@
+{
+  "data": {
+    "vehicle": {
+      "vehicleId": "fd9074f5-8d5a-4c74-ad53-d036536690ff",
+      "make": "Ford",
+      "model": "F-150",
+      "yearRange": "2018-2018",
+      "engine": "5.0L V8 Coyote",
+      "engineSlug": "5.0l-v8-coyote",
+      "engineCode": null,
+      "drivetrain": "rwd",
+      "generation": {
+        "label": "13th gen",
+        "chassisCode": "P552",
+        "yearStart": 2015,
+        "yearEnd": 2020
+      }
+    },
+    "laborTimes": [
+      {
+        "id": "c9838a3b-bae1-4b0a-8735-81e56e5ce5de",
+        "job": "Water Pump",
+        "jobSlug": "water-pump",
+        "category": "cooling",
+        "hours": 2.8,
+        "lowRange": 2.5,
+        "highRange": 2.8,
+        "confidence": "medium",
+        "updatedAt": "2026-10-31T16:57:56.668Z"
+      }
+    ]
+  },
+  "meta": {
+    "count": 1,
+    "confidenceCounts": {
+      "medium": 1
+    },
+    "timestamp": "2026-11-02T20:15:35.235Z"
+  },
+  "error": null
+}

+ 11 - 0
src/__tests__/features/integrations/fixtures/openlaborproject/not-found.json

@@ -0,0 +1,11 @@
+{
+  "data": null,
+  "meta": {
+    "timestamp": "2026-11-02T20:15:35.532Z"
+  },
+  "error": {
+    "code": "MODEL_OR_VARIANT_NOT_FOUND",
+    "message": "No labor times matched. Check the model slug — valid slugs for this make are in error.availableModels. Resolve slugs programmatically via /api/v1/vehicles.",
+    "availableModels": ["aerostar", "aspire", "bronco", "bronco-ii", "bronco-sport"]
+  }
+}

+ 213 - 0
src/__tests__/features/integrations/fluid-stock.test.ts

@@ -0,0 +1,213 @@
+import { describe, expect, it } from 'vitest'
+import {
+  amountIn,
+  fluidLineName,
+  fluidLitres,
+  fluidPartLine,
+  fluidTokens,
+  matchStock,
+  type StockPart,
+  volumeUnitOf,
+} from '@/features/integrations/Lib/fluid-stock'
+
+function part(id: string, name: string, extra: Partial<StockPart> = {}): StockPart {
+  return {
+    id,
+    name,
+    partNumber: null,
+    unit: 'l',
+    unitCost: 100,
+    sellPrice: 189,
+    quantity: 24,
+    ...extra,
+  }
+}
+
+const OIL = {
+  system: 'Engine Oil',
+  fluid: '0W-20 full synthetic',
+  spec: 'API SP, ILSAC GF-6A, dexos1 Gen 3',
+  capacityLiters: 5.7,
+  capacityQuarts: 6,
+  drainRefillLiters: null,
+  drainRefillQuarts: null,
+}
+
+describe('fluidTokens', () => {
+  it('reads grades and specs the same however they are written', () => {
+    const values = (s: string) => fluidTokens(s).map((t) => t.value)
+    expect(values('0W-20')).toEqual(['0w20'])
+    expect(values('Motorolje 0w20 5L')).toEqual(['0w20'])
+    expect(values('Bremsevæske DOT 4')).toEqual(['dot4'])
+    expect(values('Kjølevæske G13')).toEqual(['g13'])
+    expect(values('VW 504.00 / 507.00')).toEqual(expect.arrayContaining(['vw504.00', '507.00']))
+    expect(values('API SP, ILSAC GF-6A')).toEqual(expect.arrayContaining(['apisp', 'ilsacgf6a']))
+  })
+
+  it('finds nothing in a name without a code', () => {
+    expect(fluidTokens('Spylervæske')).toEqual([])
+    expect(fluidTokens(null)).toEqual([])
+  })
+})
+
+describe('matchStock', () => {
+  it("finds a Norwegian shop's oil by its grade", () => {
+    const stock = [
+      part('a', 'Motorolje 0W-20 Castrol Edge'),
+      part('b', 'Bremseklosser foran'),
+      part('c', 'Spylervæske'),
+    ]
+    expect(matchStock(OIL, stock).map((m) => m.part.id)).toEqual(['a'])
+  })
+
+  it('never offers another grade, even when a spec matches', () => {
+    const stock = [part('wrong', 'Motorolje 5W-30 API SP'), part('right', 'Motorolje 0W-20')]
+    expect(matchStock(OIL, stock).map((m) => m.part.id)).toEqual(['right'])
+  })
+
+  it('puts the closest and the stocked first, and keeps to three', () => {
+    const stock = [
+      part('grade-only-empty', 'Olje 0W-20', { quantity: 0 }),
+      part('grade-only', 'Olje 0W-20 B'),
+      part('grade-and-dexos', 'Olje 0W-20 dexos1'),
+      part('grade-and-api', 'Olje 0W-20 API SP'),
+      part('can', 'Olje 0W-20 C', { unit: 'stk' }),
+    ]
+    const ids = matchStock(OIL, stock).map((m) => m.part.id)
+    expect(ids).toHaveLength(3)
+    expect(ids.slice(0, 2).sort()).toEqual(['grade-and-api', 'grade-and-dexos'])
+    expect(ids[2]).toBe('grade-only')
+  })
+
+  it('matches brake fluid by class and never a different class', () => {
+    const brake = { fluid: 'DOT 4 brake fluid', spec: null }
+    const stock = [part('dot3', 'Bremsevæske DOT 3'), part('dot4', 'Bremsevæske DOT 4 1L')]
+    expect(matchStock(brake, stock).map((m) => m.part.id)).toEqual(['dot4'])
+  })
+
+  it('keeps the G12 coolants apart, however "plus" is written', () => {
+    const values = (t: string) => fluidTokens(t).map((x) => x.value)
+    expect(values('G12')).toEqual(['g12'])
+    expect(values('G12+')).toEqual(['g12+'])
+    expect(values('G12 plus')).toEqual(['g12+'])
+    expect(values('G12++')).toEqual(['g12++'])
+    expect(values('G 12 plus plus')).toEqual(['g12++'])
+    expect(values('G12 evo')).toEqual(['g12evo'])
+    expect(values('G13')).toEqual(['g13'])
+  })
+
+  it('never offers another G12 generation for the one the car takes', () => {
+    const coolant = { fluid: 'Coolant G12++', spec: 'VW TL 774 G' }
+    const stock = [
+      part('g12', 'Kjølevæske G12'),
+      part('g12plus', 'Kjølevæske G12+'),
+      part('g12pp', 'Kjølevæske G12 plus plus'),
+    ]
+    expect(matchStock(coolant, stock).map((m) => m.part.id)).toEqual(['g12pp'])
+    expect(matchStock({ fluid: 'G12', spec: null }, stock).map((m) => m.part.id)).toEqual(['g12'])
+  })
+
+  it('offers nothing for a fluid without a grade or spec', () => {
+    expect(matchStock({ fluid: 'Water', spec: null }, [part('a', 'Destillert vann')])).toEqual([])
+  })
+})
+
+describe('amounts', () => {
+  it('takes the drain and refill amount before the capacity', () => {
+    expect(fluidLitres({ ...OIL, drainRefillLiters: 5.2 })).toEqual({
+      litres: 5.2,
+      from: 'drainRefill',
+    })
+    expect(fluidLitres(OIL)).toEqual({ litres: 5.7, from: 'capacity' })
+  })
+
+  it('works from quarts when that is all the source gives', () => {
+    const quartsOnly = { ...OIL, capacityLiters: null, capacityQuarts: 6 }
+    expect(fluidLitres(quartsOnly)?.litres).toBeCloseTo(5.68, 2)
+  })
+
+  it('has no amount when the source gives none', () => {
+    expect(
+      fluidLitres({
+        capacityLiters: null,
+        capacityQuarts: null,
+        drainRefillLiters: null,
+        drainRefillQuarts: null,
+      })
+    ).toBeNull()
+  })
+
+  it('reads the units shops use for volume, and nothing else', () => {
+    expect(['l', 'L', 'Ltr', 'liter', 'litre', 'lt.'].map(volumeUnitOf)).toEqual(Array(6).fill('l'))
+    expect(volumeUnitOf('ml')).toBe('ml')
+    expect(volumeUnitOf('qt')).toBe('qt')
+    expect(volumeUnitOf('stk')).toBeNull()
+    expect(volumeUnitOf(null)).toBeNull()
+  })
+
+  it('converts to the unit the item is sold in', () => {
+    expect(amountIn(5.7, 'l')).toBe(5.7)
+    expect(amountIn(5.7, 'ml')).toBe(5700)
+    expect(amountIn(5.7, 'qt')).toBe(6.02)
+    expect(amountIn(5.7, 'stk')).toBeNull()
+  })
+})
+
+describe('fluidPartLine', () => {
+  it('adds a stocked item in its own unit at its price', () => {
+    const line = fluidPartLine(OIL, { units: 'both', part: part('a', 'Motorolje 0W-20') })
+    expect(line).toEqual({
+      partNumber: '',
+      name: 'Motorolje 0W-20',
+      quantity: 5.7,
+      unit: 'l',
+      category: null,
+      unitCost: 100,
+      markupPercent: 0,
+      unitPrice: 189,
+      total: 1077.3,
+      inventoryPartId: 'a',
+    })
+  })
+
+  it("prices it the way the page does, with the workshop's markup when that is the rule", () => {
+    const line = fluidPartLine(OIL, {
+      units: 'both',
+      part: part('a', 'Motorolje 0W-20'),
+      pricing: { defaultMarkupPercent: 50, markupAppliesToInventory: true },
+    })
+    expect(line).toMatchObject({ unitPrice: 150, markupPercent: 50, total: 855 })
+  })
+
+  it('adds one of an item sold per piece, for the shop to adjust', () => {
+    const can = part('can', 'Motorolje 0W-20 5L kanne', { unit: 'stk', sellPrice: 899 })
+    expect(fluidPartLine(OIL, { units: 'both', part: can })).toMatchObject({
+      quantity: 1,
+      unit: 'stk',
+      total: 899,
+    })
+  })
+
+  it('adds a plain line with the amount and no price when nothing is stocked', () => {
+    expect(fluidPartLine(OIL, { units: 'metric' })).toEqual({
+      partNumber: '',
+      name: 'Engine Oil, 0W-20 full synthetic (API SP, ILSAC GF-6A, dexos1 Gen 3)',
+      quantity: 5.7,
+      unit: 'l',
+      category: null,
+      unitCost: 0,
+      markupPercent: 0,
+      unitPrice: 0,
+      total: 0,
+      inventoryPartId: null,
+    })
+    expect(fluidPartLine(OIL, { units: 'us' })).toMatchObject({ quantity: 6.02, unit: 'qt' })
+  })
+
+  it('names a plain line from what the source gives', () => {
+    expect(fluidLineName({ system: 'Coolant', fluid: null, spec: null })).toBe('Coolant')
+    expect(fluidLineName({ system: 'Coolant', fluid: 'OAT', spec: 'G13' })).toBe(
+      'Coolant, OAT (G13)'
+    )
+  })
+})

+ 564 - 0
src/__tests__/features/integrations/openlaborproject.test.ts

@@ -0,0 +1,564 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { deleteMany } = vi.hoisted(() => ({ deleteMany: vi.fn(async () => ({ count: 0 })) }))
+vi.mock('@/lib/db', () => ({ db: { repairDataCache: { deleteMany } } }))
+vi.mock('next-intl/server', () => ({ getTranslations: vi.fn(async () => (k: string) => k) }))
+
+import { CHECK_PLAN_JOB } from '@/features/integrations/Lib/repair-data-contract'
+import {
+  type ConnectorContext,
+  type FluidSpec,
+  type LaborTime,
+  RepairDataKeyRejected,
+} from '@/features/integrations/Lib/types'
+import { SERVER_KEY_ENV } from '@/integrations/openlaborproject/manifest'
+import {
+  confidenceOf,
+  connector,
+  keepAllowed,
+  mapFluidSpec,
+  mapLaborTime,
+  modelSlug,
+  resolveModel,
+  slug,
+  toAnswer,
+} from '@/integrations/openlaborproject/server'
+
+/**
+ * Replies recorded from the live service on 2 November 2026 with a free
+ * key, trimmed to a few engines and jobs: a whole model year (2018 Ford
+ * F-150), one job for one variant, the fluids of a 2020 Honda Civic, one
+ * fluid system, and the refusal for a model the service does not know.
+ */
+const FIXTURES = path.join(__dirname, 'fixtures/openlaborproject')
+function fixture<T = unknown>(name: string): T {
+  return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8'))
+}
+
+/** The bad-key refusal, word for word as the live service sent it. */
+const BAD_KEY = {
+  data: null,
+  meta: {},
+  error: { code: 'INVALID_API_KEY', message: 'Invalid or revoked API key.' },
+}
+const MISSING_PARAMS = {
+  data: null,
+  meta: {},
+  error: {
+    code: 'MISSING_PARAMS',
+    message:
+      'Required: make, model, year (YYYY or YYYY-YYYY) — or a vin. Optional: engine, job, q.',
+  },
+}
+
+const VIN = '1FTFW1E50JFA12345'
+
+function ctx(): ConnectorContext {
+  return {
+    connection: {
+      id: 'c1',
+      organizationId: 'org1',
+      connectorId: 'openlaborproject',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials: { apiKey: 'test-key' },
+    http: { fetch: vi.fn(), json: vi.fn() },
+    links: {} as ConnectorContext['links'],
+    log: vi.fn(async () => undefined),
+    saveState: vi.fn(async () => undefined),
+    timezone: 'UTC',
+    appUrl: 'http://localhost',
+  } as unknown as ConnectorContext
+}
+
+/** A reply as the service sends it; `tier` is the plan it names in x-ratelimit-tier. */
+function reply(status: number, body: unknown, tier: string | null = 'hobbyist'): Response {
+  const headers: Record<string, string> = { 'content-type': 'application/json' }
+  if (tier) headers['x-ratelimit-tier'] = tier
+  return new Response(JSON.stringify(body), { status, headers })
+}
+
+const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>()
+
+function urlOf(call: number): URL {
+  return new URL(fetchMock.mock.calls[call][0])
+}
+
+beforeEach(() => {
+  fetchMock.mockReset()
+  vi.stubGlobal('fetch', fetchMock)
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.unstubAllEnvs()
+})
+
+describe('mapping', () => {
+  it('reads a whole model year as one variant per engine and drivetrain', () => {
+    const answer = toAnswer(
+      fixture<{ data: unknown }>('labor-model').data,
+      'laborTimes',
+      mapLaborTime
+    )
+    expect(answer?.make).toBe('Ford')
+    expect(answer?.model).toBe('F-150')
+    expect(answer?.variants.map((v) => [v.engine, v.engineId, v.drivetrain])).toEqual([
+      ['2.7L V6 EcoBoost', '2.7l-v6-ecoboost', 'rwd'],
+      ['3.0L V6 Power Stroke Diesel', '3.0l-v6-diesel', 'rwd'],
+      ['5.0L V8 Coyote', '5.0l-v8-coyote', '4wd'],
+    ])
+    expect(answer?.variants[2].vehicleId).toBe('92a92bb0-7293-4783-97e9-743c15473972')
+  })
+
+  it('keeps the one job a free key unlocks and lists the rest without hours', () => {
+    const answer = toAnswer(
+      fixture<{ data: unknown }>('labor-model').data,
+      'laborTimes',
+      mapLaborTime
+    )
+    const jobs = answer?.variants[0].items as LaborTime[]
+    expect(jobs.find((j) => j.id === 'brake-pads-rotors-front')).toEqual({
+      id: 'brake-pads-rotors-front',
+      name: 'Brake Pads and Rotors - Front',
+      category: 'brakes',
+      hours: 2.1,
+      low: 1.7,
+      high: 2.5,
+      confidence: 'high',
+      locked: false,
+    })
+    expect(jobs.find((j) => j.id === 'alternator')).toMatchObject({
+      hours: null,
+      confidence: null,
+      locked: true,
+    })
+  })
+
+  it('reads one job for one variant from the single-vehicle shape', () => {
+    const answer = toAnswer(
+      fixture<{ data: unknown }>('labor-one-job').data,
+      'laborTimes',
+      mapLaborTime
+    )
+    expect(answer?.variants).toHaveLength(1)
+    expect(answer?.variants[0]).toMatchObject({
+      engineId: '5.0l-v8-coyote',
+      drivetrain: 'rwd',
+      vehicleId: 'fd9074f5-8d5a-4c74-ad53-d036536690ff',
+    })
+    expect(answer?.variants[0].items[0]).toMatchObject({
+      id: 'water-pump',
+      hours: 2.8,
+      confidence: 'medium',
+      locked: false,
+    })
+  })
+
+  it('reads fluids, which carry no vehicle id, and the one system a free key unlocks', () => {
+    const answer = toAnswer(
+      fixture<{ data: unknown }>('fluids-model').data,
+      'fluidSpecs',
+      mapFluidSpec
+    )
+    expect(answer?.variants.map((v) => [v.engineId, v.vehicleId])).toEqual([
+      ['1.5l-i4-turbo', null],
+      ['2.0l-i4', null],
+    ])
+    const fluids = answer?.variants.flatMap((v) => v.items) as FluidSpec[]
+    const oil = fluids.find((f) => !f.locked)
+    expect(oil).toMatchObject({
+      id: 'engine_oil',
+      system: 'Engine Oil',
+      fluid: '0W-20 full synthetic',
+      capacityLiters: 3.5,
+      capacityQuarts: 3.7,
+      intervalKm: 8045,
+      confidence: 'estimated',
+    })
+    expect(fluids.filter((f) => f.locked).length).toBeGreaterThan(0)
+  })
+
+  it('reads one fluid system', () => {
+    const answer = toAnswer(
+      fixture<{ data: unknown }>('fluids-one-system').data,
+      'fluidSpecs',
+      mapFluidSpec
+    )
+    expect(answer?.variants[0].items[0]).toMatchObject({
+      id: 'coolant',
+      capacityLiters: 5.58,
+      locked: false,
+    })
+  })
+
+  it('folds the confidence levels into four', () => {
+    expect(confidenceOf('oem_matched')).toBe('oem')
+    expect(confidenceOf('oem_verified')).toBe('oem')
+    expect(confidenceOf('high')).toBe('high')
+    expect(confidenceOf('medium')).toBe('medium')
+    expect(confidenceOf('estimated')).toBe('estimated')
+    expect(confidenceOf(null)).toBeNull()
+    expect(confidenceOf('something new')).toBeNull()
+  })
+
+  it('spells make and model the way the service does in its URLs', () => {
+    expect(slug('F-150')).toBe('f-150')
+    expect(slug('Mercedes-Benz')).toBe('mercedes-benz')
+    expect(slug('Land Rover')).toBe('land-rover')
+    expect(slug('Citroën')).toBe('citroen')
+    expect(slug(' 3 Series ')).toBe('3-series')
+  })
+
+  it('has no answer for an empty reply', () => {
+    expect(toAnswer(null, 'laborTimes', mapLaborTime)).toBeNull()
+    expect(toAnswer({ engines: [] }, 'laborTimes', mapLaborTime)).toBeNull()
+  })
+})
+
+describe('labor times', () => {
+  const query = { make: 'Ford', model: 'F-150', year: 2018 }
+
+  it('asks by make, model and year with the key in a header', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-model')))
+    const res = await connector.laborTimes?.(ctx(), query)
+    expect(res?.answer?.variants).toHaveLength(3)
+    expect(res?.answer).toMatchObject({ matchedBy: 'model', region: 'US' })
+    const url = urlOf(0)
+    expect(url.pathname).toBe('/api/v1/labor-times')
+    expect(Object.fromEntries(url.searchParams)).toEqual({
+      make: 'ford',
+      model: 'f-150',
+      year: '2018',
+    })
+    const headers = fetchMock.mock.calls[0][1]?.headers as Record<string, string>
+    expect(headers['x-api-key']).toBe('test-key')
+    expect(url.searchParams.has('key')).toBe(false)
+  })
+
+  it('tries the VIN when the service spells the model differently', async () => {
+    fetchMock
+      .mockResolvedValueOnce(reply(404, fixture('not-found')))
+      .mockResolvedValueOnce(reply(200, fixture('labor-one-job')))
+    const c = ctx()
+    const res = await connector.laborTimes?.(c, { ...query, model: 'F150', vin: VIN })
+    expect(res?.answer?.variants).toHaveLength(1)
+    expect(res?.answer?.matchedBy).toBe('vin')
+    expect(urlOf(0).searchParams.get('model')).toBe('f150')
+    expect(Object.fromEntries(urlOf(1).searchParams)).toEqual({ vin: VIN })
+    // The VIN identifies one vehicle; the log has make, model and year only.
+    expect(JSON.stringify(vi.mocked(c.log).mock.calls)).not.toContain(VIN)
+  })
+
+  it('has no answer for a model it does not know and no VIN to fall back on', async () => {
+    fetchMock.mockResolvedValueOnce(reply(404, fixture('not-found')))
+    expect(await connector.laborTimes?.(ctx(), { ...query, model: 'Nonexistent' })).toEqual({
+      answer: null,
+      keep: false,
+    })
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('leaves out a VIN that cannot be one', async () => {
+    fetchMock.mockResolvedValueOnce(reply(404, fixture('not-found')))
+    expect((await connector.laborTimes?.(ctx(), { ...query, vin: 'NOT-A-VIN' }))?.answer).toBeNull()
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('asks for one job of one variant', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job')))
+    await connector.laborTimes?.(ctx(), {
+      ...query,
+      variant: {
+        engineId: '5.0l-v8-coyote',
+        drivetrain: 'rwd',
+        vehicleId: 'fd9074f5-8d5a-4c74-ad53-d036536690ff',
+      },
+      item: 'water-pump',
+    })
+    expect(Object.fromEntries(urlOf(0).searchParams)).toMatchObject({
+      engine: '5.0l-v8-coyote',
+      vehicle_id: 'fd9074f5-8d5a-4c74-ad53-d036536690ff',
+      job: 'water-pump',
+    })
+  })
+
+  it('says so when the key is refused, as the error the stored answers are deleted on', async () => {
+    fetchMock.mockResolvedValueOnce(reply(401, BAD_KEY, null))
+    const err = await connector.laborTimes?.(ctx(), query).catch((e: unknown) => e)
+    expect(err).toBeInstanceOf(RepairDataKeyRejected)
+    expect((err as Error).message).toBe('Open Labor Project rejected the API key')
+  })
+
+  it("passes on the service's own words when the key has used its calls", async () => {
+    fetchMock.mockResolvedValueOnce(
+      reply(429, { data: null, error: { code: 'RATE_LIMITED', message: 'Daily limit reached.' } })
+    )
+    await expect(connector.laborTimes?.(ctx(), query)).rejects.toThrow(
+      'Open Labor Project: Daily limit reached.'
+    )
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('says so when the service cannot be reached', async () => {
+    fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
+    await expect(connector.laborTimes?.(ctx(), query)).rejects.toThrow(
+      'Open Labor Project could not be reached'
+    )
+  })
+})
+
+describe('fluids', () => {
+  it('asks for one system with q', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('fluids-one-system')))
+    const res = await connector.fluidSpecs?.(ctx(), {
+      make: 'Honda',
+      model: 'Civic',
+      year: 2020,
+      variant: { engineId: '2.0l-i4', drivetrain: 'fwd', vehicleId: null },
+      item: 'coolant',
+    })
+    expect(urlOf(0).pathname).toBe('/api/v1/fluid-specs')
+    expect(Object.fromEntries(urlOf(0).searchParams)).toEqual({
+      make: 'honda',
+      model: 'civic',
+      year: '2020',
+      engine: '2.0l-i4',
+      q: 'coolant',
+    })
+    expect(res?.answer?.variants[0].items[0]).toMatchObject({ id: 'coolant', locked: false })
+  })
+})
+
+describe('whether an answer may be kept', () => {
+  const query = { make: 'Ford', model: 'F-150', year: 2018 }
+
+  it('keeps what a paid plan fetched, as the service allows while it stays subscribed', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'builder'))
+    expect((await connector.laborTimes?.(ctx(), query))?.keep).toBe(true)
+  })
+
+  it('keeps nothing a free key fetched', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'hobbyist'))
+    expect((await connector.laborTimes?.(ctx(), query))?.keep).toBe(false)
+  })
+
+  it('reads the free tier from the body too, and keeps nothing a reply does not name a plan for', () => {
+    const paid = new Headers({ 'x-ratelimit-tier': 'Business' })
+    expect(keepAllowed(paid, {})).toBe(true)
+    // The recorded whole-model reply carries meta.freeTier.
+    expect(keepAllowed(paid, fixture('labor-model'))).toBe(false)
+    expect(keepAllowed(new Headers(), {})).toBe(false)
+    expect(keepAllowed(new Headers({ 'x-ratelimit-tier': 'free' }), {})).toBe(false)
+  })
+})
+
+describe('the daily plan check', () => {
+  it('asks once with the cheap lookup and reads the plan off the reply', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'builder'))
+    expect(await connector.repairDataPlan?.(ctx())).toEqual({ keep: true })
+    expect(urlOf(0).searchParams.get('job')).toBe('water-pump')
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'hobbyist'))
+    expect(await connector.repairDataPlan?.(ctx())).toEqual({ keep: false })
+  })
+
+  it('says nothing either way when the reply names no plan', async () => {
+    fetchMock.mockResolvedValueOnce(reply(429, { data: null }, null))
+    expect(await connector.repairDataPlan?.(ctx())).toEqual({ keep: null })
+  })
+
+  it('throws the refused-key error for a key that no longer works', async () => {
+    fetchMock.mockResolvedValueOnce(reply(401, BAD_KEY, null))
+    await expect(connector.repairDataPlan?.(ctx())).rejects.toBeInstanceOf(RepairDataKeyRejected)
+  })
+
+  it('is scheduled with a handler behind it', () => {
+    expect(connector.manifest.schedules).toContainEqual({
+      job: CHECK_PLAN_JOB,
+      everyMinutes: 1440,
+    })
+    expect(connector.jobs[CHECK_PLAN_JOB]).toBeTypeOf('function')
+  })
+})
+
+describe('the key the server holds', () => {
+  it("is used when the workshop's own key is empty", async () => {
+    vi.stubEnv(SERVER_KEY_ENV, 'server-key')
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'business'))
+    const c = ctx()
+    c.credentials = {}
+    await connector.laborTimes?.(c, { make: 'Ford', model: 'F-150', year: 2018 })
+    const headers = fetchMock.mock.calls[0][1]?.headers as Record<string, string>
+    expect(headers['x-api-key']).toBe('server-key')
+  })
+
+  it("never replaces a workshop's own key", async () => {
+    vi.stubEnv(SERVER_KEY_ENV, 'server-key')
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job')))
+    await connector.laborTimes?.(ctx(), { make: 'Ford', model: 'F-150', year: 2018 })
+    const headers = fetchMock.mock.calls[0][1]?.headers as Record<string, string>
+    expect(headers['x-api-key']).toBe('test-key')
+  })
+})
+
+describe('connection test', () => {
+  it('asks for one job on one listed vehicle, never a call with no vehicle', async () => {
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job')))
+    expect(await connector.test(ctx())).toEqual({ ok: true })
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+    expect(urlOf(0).pathname).toBe('/api/v1/labor-times')
+    expect(Object.fromEntries(urlOf(0).searchParams)).toEqual({
+      make: 'ford',
+      model: 'f-150',
+      year: '2018',
+      engine: '5.0l-v8-coyote',
+      job: 'water-pump',
+    })
+  })
+
+  it('passes a good key that has used its calls for the day', async () => {
+    fetchMock.mockResolvedValueOnce(
+      reply(429, { data: null, meta: {}, error: { code: 'RATE_LIMITED', message: 'Daily limit' } })
+    )
+    expect(await connector.test(ctx())).toEqual({ ok: true })
+  })
+
+  it('fails when the service turns the call away for another reason', async () => {
+    fetchMock.mockResolvedValueOnce(reply(400, MISSING_PARAMS))
+    expect(await connector.test(ctx())).toEqual({
+      ok: false,
+      message: 'Open Labor Project answered HTTP 400',
+    })
+  })
+
+  it('fails on a refused key, and deletes what that key stored', async () => {
+    fetchMock.mockResolvedValueOnce(reply(401, BAD_KEY, null))
+    expect(await connector.test(ctx())).toEqual({
+      ok: false,
+      message: 'Open Labor Project rejected the API key',
+    })
+    expect(deleteMany).toHaveBeenCalledWith({ where: { source: 'openlaborproject', owner: 'c1' } })
+  })
+
+  it("deletes what was stored once the key's plan is free, and keeps it on a paid one", async () => {
+    deleteMany.mockClear()
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'business'))
+    await connector.test(ctx())
+    expect(deleteMany).not.toHaveBeenCalled()
+    fetchMock.mockResolvedValueOnce(reply(200, fixture('labor-one-job'), 'hobbyist'))
+    await connector.test(ctx())
+    expect(deleteMany).toHaveBeenCalledTimes(1)
+  })
+
+  it('fails without a key, before calling out', async () => {
+    const c = ctx()
+    c.credentials = {}
+    expect((await connector.test(c)).ok).toBe(false)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+})
+
+describe('finding the model', () => {
+  const notFound = (availableModels: string[]) => ({
+    data: null,
+    meta: {},
+    error: {
+      code: 'MODEL_OR_VARIANT_NOT_FOUND',
+      message: 'No labor times matched.',
+      availableModels,
+    },
+  })
+  const BMW = ['1-series', '3-series', '5-series', 'x5']
+
+  it('asks under the name the service likely uses, not the one a European shop writes', () => {
+    expect(modelSlug('3-Serie (E9*)')).toBe('3-series')
+    expect(modelSlug('5-Serie F11')).toBe('5-series-f11')
+    expect(modelSlug('C-Klasse')).toBe('c-class')
+    expect(modelSlug('3er')).toBe('3-series')
+    expect(modelSlug('F-150')).toBe('f-150')
+    expect(modelSlug('Kuga')).toBe('kuga')
+  })
+
+  it("picks the service's own slug from the list a not-found reply carries", () => {
+    expect(resolveModel('5-Serie F11', BMW)).toBe('5-series')
+    expect(resolveModel('X5 E70', BMW)).toBe('x5')
+    expect(resolveModel('F150', ['f-150', 'f-250'])).toBe('f-150')
+    expect(resolveModel('Kuga', ['escape', 'focus'])).toBeNull()
+  })
+
+  it('asks once more under the matching slug instead of trying the VIN', async () => {
+    fetchMock
+      .mockResolvedValueOnce(reply(404, notFound(BMW)))
+      .mockResolvedValueOnce(reply(200, fixture('labor-one-job')))
+    const res = await connector.laborTimes?.(ctx(), {
+      make: 'BMW',
+      model: '5-Serie F11',
+      year: 2011,
+      vin: 'WBAFW11010C123456',
+    })
+    expect(fetchMock).toHaveBeenCalledTimes(2)
+    expect(urlOf(0).searchParams.get('model')).toBe('5-series-f11')
+    expect(urlOf(1).searchParams.get('model')).toBe('5-series')
+    expect(urlOf(1).searchParams.has('vin')).toBe(false)
+    expect(res?.answer).toMatchObject({ matchedBy: 'model', modelId: '5-series' })
+  })
+
+  it('stops when the service knows the model but has nothing for it', async () => {
+    fetchMock.mockResolvedValueOnce(reply(404, notFound(['kuga', 'focus'])))
+    const res = await connector.fluidSpecs?.(ctx(), {
+      make: 'Ford',
+      model: 'Kuga',
+      year: 2014,
+      vin: VIN,
+    })
+    expect(res?.answer).toBeNull()
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('asks under a name an earlier lookup found, and does not fall back to the VIN', async () => {
+    fetchMock.mockResolvedValueOnce(reply(404, notFound([])))
+    const res = await connector.fluidSpecs?.(ctx(), {
+      make: 'Ford',
+      model: 'Kuga',
+      year: 2014,
+      vin: VIN,
+      modelId: 'kuga',
+    })
+    expect(res?.answer).toBeNull()
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+    expect(urlOf(0).searchParams.get('model')).toBe('kuga')
+  })
+
+  it('reads a VIN the service decodes but cannot place as not found, not as a failure', async () => {
+    fetchMock.mockResolvedValueOnce(reply(404, notFound(['escape']))).mockResolvedValueOnce(
+      reply(400, {
+        data: null,
+        meta: {},
+        error: {
+          code: 'MISSING_PARAMS',
+          message:
+            'VIN decoded to bmw 3 Series but that vehicle is not in our catalog (see vinDecode).',
+        },
+      })
+    )
+    const res = await connector.laborTimes?.(ctx(), {
+      make: 'BMW',
+      model: 'E30',
+      year: 1988,
+      vin: 'WBAAD11000AB12345',
+    })
+    expect(res?.answer).toBeNull()
+    expect(Object.fromEntries(urlOf(1).searchParams)).toEqual({ vin: 'WBAAD11000AB12345' })
+  })
+
+  it('still fails a call with no vehicle the service turns away for another reason', async () => {
+    fetchMock.mockResolvedValueOnce(reply(400, MISSING_PARAMS))
+    await expect(
+      connector.laborTimes?.(ctx(), { make: 'Ford', model: 'F-150', year: 2018 })
+    ).rejects.toThrow('Open Labor Project: Required')
+  })
+})

+ 639 - 0
src/__tests__/features/integrations/repair-data.test.ts

@@ -0,0 +1,639 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn() }))
+vi.mock('next-intl/server', () => ({ getTranslations: vi.fn(async () => (k: string) => k) }))
+vi.mock('@/features/integrations/Lib/display-name', () => ({
+  connectorName: () => 'Open Labor Project',
+}))
+vi.mock('@/features/integrations/Lib/connections', () => ({ loadConnection: vi.fn() }))
+
+/**
+ * The stored answers as a table in memory, with the few queries the guide
+ * runs against it, so storing, deleting and refreshing are tested the way
+ * they behave rather than as a list of calls.
+ */
+interface Row {
+  id: string
+  source: string
+  owner: string
+  connectionId: string | null
+  kind: string
+  lookupKey: string
+  version: number
+  data: unknown
+  fetchedAt: Date
+}
+const table: Row[] = []
+function matches(row: Row, where: Record<string, unknown>) {
+  return Object.entries(where).every(([k, v]) => {
+    const value = row[k as keyof Row]
+    if (v && typeof v === 'object' && 'in' in (v as object))
+      return (v as { in: unknown[] }).in.includes(value)
+    return value === v
+  })
+}
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn(async () => ({ isSuperAdmin: false })) },
+    vehicle: { findFirst: vi.fn() },
+    integrationConnection: { findMany: vi.fn() },
+    repairDataCache: {
+      findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) =>
+        table.filter((r) => matches(r, where)).map((r) => ({ ...r }))
+      ),
+      count: vi.fn(
+        async ({ where }: { where: Record<string, unknown> }) =>
+          table.filter((r) => matches(r, where)).length
+      ),
+      deleteMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        const gone = table.filter((r) => matches(r, where))
+        for (const r of gone) table.splice(table.indexOf(r), 1)
+        return { count: gone.length }
+      }),
+      upsert: vi.fn(
+        async (args: {
+          where: { source_owner_kind_lookupKey: Record<string, string> }
+          create: Omit<Row, 'id' | 'fetchedAt'>
+          update: Partial<Row>
+        }) => {
+          const key = args.where.source_owner_kind_lookupKey
+          const found = table.find((r) => matches(r, key))
+          if (found) Object.assign(found, args.update)
+          else table.push({ id: `row${table.length + 1}`, fetchedAt: new Date(), ...args.create })
+        }
+      ),
+      update: vi.fn(async ({ where, data }: { where: { id: string }; data: Partial<Row> }) => {
+        const found = table.find((r) => r.id === where.id)
+        if (found) Object.assign(found, data)
+      }),
+    },
+  },
+}))
+
+import {
+  getRepairData,
+  isRepairDataAvailable,
+  unlockRepairDataItem,
+} from '@/features/integrations/Actions/repairDataActions'
+import { loadConnection } from '@/features/integrations/Lib/connections'
+import {
+  CHECK_PLAN_JOB,
+  clearRepairDataCache,
+  litresOf,
+  matchVariant,
+  PLATFORM_OWNER,
+  pickVariant,
+  REFRESH_AFTER_DAYS,
+  repairDataJobs,
+  usesServerKey,
+} from '@/features/integrations/Lib/repair-data'
+import {
+  type ConnectorContext,
+  type LaborTime,
+  type RepairDataAnswer,
+  RepairDataKeyRejected,
+  type RepairDataReply,
+} from '@/features/integrations/Lib/types'
+import { manifest, SERVER_KEY_ENV } from '@/integrations/openlaborproject/manifest'
+import { mapLaborTime, toAnswer } from '@/integrations/openlaborproject/server'
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+
+const FIXTURES = path.join(__dirname, 'fixtures/openlaborproject')
+function answerOf(name: string): RepairDataAnswer<LaborTime> {
+  const body = JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8'))
+  const answer = toAnswer(body.data, 'laborTimes', mapLaborTime) as RepairDataAnswer<LaborTime>
+  return { ...answer, matchedBy: 'model', region: 'US' }
+}
+/** Every job unlocked, the way a paid key answers. */
+function paidAnswer(): RepairDataAnswer<LaborTime> {
+  const answer = answerOf('labor-model')
+  for (const v of answer.variants) {
+    v.items = v.items.map((i, n) => ({
+      ...i,
+      hours: i.hours ?? 1 + n / 10,
+      confidence: i.confidence ?? (n % 2 ? 'estimated' : 'high'),
+      locked: false,
+    }))
+  }
+  return answer
+}
+function paid(answer = paidAnswer()): RepairDataReply<LaborTime> {
+  return { answer, keep: true }
+}
+function free(answer: RepairDataAnswer<LaborTime> | null = answerOf('labor-model')) {
+  return { answer, keep: false }
+}
+
+const laborTimes = vi.fn()
+const fluidSpecs = vi.fn()
+let ctx: ConnectorContext
+
+function connected(settings: Record<string, unknown> = {}, ...connectorIds: string[]) {
+  const ids = connectorIds.length ? connectorIds : ['openlaborproject']
+  vi.mocked(db.integrationConnection.findMany).mockResolvedValue(
+    ids.map((connectorId) => ({ id: 'conn1', connectorId, settings })) as never
+  )
+  ctx.connection.settings = settings
+}
+
+function vehicle(fields: Record<string, unknown> = {}) {
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+    make: 'Ford',
+    model: 'F-150',
+    year: 2018,
+    vin: null,
+    engineSize: '5.0 L',
+    fuelType: 'gasoline',
+    ...fields,
+  } as never)
+}
+
+let org = 0
+beforeEach(() => {
+  vi.clearAllMocks()
+  clearRepairDataCache()
+  table.length = 0
+  // A fresh organisation per case, so the per-minute lookup budget never runs out.
+  org += 1
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'u1', email: 'a@b.c', name: 'A', isSuperAdmin: false },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: `org${org}`,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(getFeatures).mockResolvedValue({ integrations: true } as never)
+  ctx = {
+    connection: {
+      id: 'conn1',
+      organizationId: `org${org}`,
+      connectorId: 'openlaborproject',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials: { apiKey: 'workshop-key' },
+    log: vi.fn(async () => undefined),
+    saveState: vi.fn(async (patch: Record<string, unknown>) => {
+      ctx.connection.state = { ...ctx.connection.state, ...patch }
+    }),
+  } as unknown as ConnectorContext
+  vi.mocked(loadConnection).mockImplementation(
+    async () =>
+      ({
+        ctx,
+        server: { manifest, laborTimes, fluidSpecs },
+        status: 'active',
+      }) as never
+  )
+  connected()
+  vehicle()
+})
+
+afterEach(() => {
+  vi.unstubAllEnvs()
+  vi.useRealTimers()
+})
+
+async function open(input: Partial<Parameters<typeof getRepairData>[0]> = {}) {
+  const res = await getRepairData({ vehicleId: 'v1', kind: 'labor', ...input })
+  if (res.data?.status !== 'found') throw new Error(`not found: ${JSON.stringify(res)}`)
+  return res.data.value
+}
+
+describe('isRepairDataAvailable', () => {
+  it('offers labor times and fluids once Open Labor Project is connected', async () => {
+    expect((await isRepairDataAvailable()).data).toEqual({ labor: true, fluids: true })
+  })
+
+  it('leaves the fluids tab out when the workshop turned it off', async () => {
+    connected({ fluids: false })
+    expect((await isRepairDataAvailable()).data).toEqual({ labor: true, fluids: false })
+  })
+
+  it('offers nothing without a connection, or when the plan has no integrations', async () => {
+    connected({}, 'nhtsa')
+    expect((await isRepairDataAvailable()).data).toEqual({ labor: false, fluids: false })
+    connected()
+    vi.mocked(getFeatures).mockResolvedValue({ integrations: false } as never)
+    expect((await isRepairDataAvailable()).data).toEqual({ labor: false, fluids: false })
+  })
+})
+
+describe('getRepairData', () => {
+  it("opens on the engine that matches the vehicle's and sends that engine's jobs only", async () => {
+    laborTimes.mockResolvedValue(free())
+    const view = await open()
+    expect(view).toMatchObject({
+      connector: 'openlaborproject',
+      source: 'Open Labor Project',
+      logo: '/images/integrations/openlaborproject.svg',
+      region: 'US',
+      selected: 2,
+      match: 'engine',
+      hidden: 0,
+    })
+    expect(view.variants.map((v) => v.label)).toEqual([
+      '2.7L V6 EcoBoost, RWD',
+      '3.0L V6 Power Stroke Diesel, RWD',
+      '5.0L V8 Coyote, 4WD',
+    ])
+    expect(view.items).toHaveLength(4)
+    expect(laborTimes).toHaveBeenCalledWith(ctx, {
+      make: 'Ford',
+      model: 'F-150',
+      year: 2018,
+      vin: undefined,
+      variant: undefined,
+      item: undefined,
+    })
+  })
+
+  it('switches engine from the remembered answer without another call', async () => {
+    laborTimes.mockResolvedValue(free())
+    await open()
+    const view = await open({
+      variant: { engineId: '2.7l-v6-ecoboost', drivetrain: 'rwd', vehicleId: null },
+    })
+    expect(view.selected).toBe(0)
+    expect(view.match).toBe('chosen')
+    expect(laborTimes).toHaveBeenCalledTimes(1)
+  })
+
+  it('warns when no listed engine is the one on file', async () => {
+    laborTimes.mockResolvedValue(free())
+    vehicle({ engineSize: '', fuelType: 'electric' })
+    expect((await open()).match).toBe('none')
+  })
+
+  it("leaves out times below the workshop's minimum confidence and says how many", async () => {
+    connected({ minConfidence: 'high' })
+    laborTimes.mockResolvedValue(paid())
+    const view = await open()
+    expect(view.items.every((i) => i.confidence === 'high' || i.confidence === 'oem')).toBe(true)
+    expect(view.hidden).toBeGreaterThan(0)
+    expect(view.items.length + view.hidden).toBe(4)
+    expect(view.prefs.minConfidence).toBe('high')
+  })
+
+  it('says the source has nothing when it does not know the vehicle', async () => {
+    laborTimes.mockResolvedValue(free(null))
+    expect((await getRepairData({ vehicleId: 'v1', kind: 'labor' })).data).toEqual({
+      status: 'none',
+    })
+  })
+
+  it("hands the source's refusal to the dialog instead of failing the action", async () => {
+    laborTimes.mockRejectedValue(new Error('Open Labor Project: Daily limit reached.'))
+    const res = await getRepairData({ vehicleId: 'v1', kind: 'labor' })
+    expect(res.success).toBe(true)
+    expect(res.data).toEqual({
+      status: 'failed',
+      message: 'Open Labor Project: Daily limit reached.',
+    })
+  })
+
+  it('asks for a make, model and year before calling out', async () => {
+    vehicle({ model: ' ' })
+    const res = await getRepairData({ vehicleId: 'v1', kind: 'labor' })
+    expect(res.data?.status).toBe('failed')
+    expect(laborTimes).not.toHaveBeenCalled()
+  })
+
+  it("looks up only this workshop's own vehicles", async () => {
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
+    const res = await getRepairData({ vehicleId: 'someone-elses', kind: 'labor' })
+    expect(res.data).toEqual({ status: 'failed', message: 'Vehicle not found' })
+    expect(vi.mocked(db.vehicle.findFirst).mock.calls[0][0]).toMatchObject({
+      where: { id: 'someone-elses', organizationId: `org${org}` },
+    })
+  })
+
+  it('refuses an engine id that is not one the source hands out', async () => {
+    const res = await getRepairData({
+      vehicleId: 'v1',
+      kind: 'labor',
+      variant: { engineId: '../../etc', drivetrain: null, vehicleId: null },
+    })
+    expect(res.success).toBe(false)
+    expect(laborTimes).not.toHaveBeenCalled()
+  })
+
+  it('will not open fluids the workshop turned off', async () => {
+    connected({ fluids: false })
+    const res = await getRepairData({ vehicleId: 'v1', kind: 'fluids' })
+    expect(res.data?.status).toBe('failed')
+    expect(fluidSpecs).not.toHaveBeenCalled()
+  })
+})
+
+describe('asking the source as little as possible', () => {
+  it('asks for fluids under the model name the labor lookup found', async () => {
+    laborTimes.mockResolvedValue(free({ ...answerOf('labor-model'), modelId: 'f-150-crew' }))
+    fluidSpecs.mockResolvedValue({ answer: null, keep: false })
+    await open()
+    await getRepairData({ vehicleId: 'v1', kind: 'fluids' })
+    expect(fluidSpecs.mock.calls[0][1]).toMatchObject({ modelId: 'f-150-crew' })
+  })
+
+  it('asks for one value under the name the whole list was found under', async () => {
+    laborTimes.mockResolvedValueOnce(free({ ...answerOf('labor-model'), modelId: 'f-150-crew' }))
+    await open()
+    laborTimes.mockResolvedValueOnce(free(null))
+    await unlockRepairDataItem({
+      vehicleId: 'v1',
+      kind: 'labor',
+      variant: { engineId: '5.0l-v8-coyote', drivetrain: '4wd', vehicleId: null },
+      item: 'water-pump',
+    })
+    expect(laborTimes.mock.calls[1][1]).toMatchObject({ modelId: 'f-150-crew', item: 'water-pump' })
+  })
+
+  it('remembers a vehicle the source has nothing for, so asking again costs no call', async () => {
+    laborTimes.mockResolvedValue(free(null))
+    await getRepairData({ vehicleId: 'v1', kind: 'labor' })
+    vi.useFakeTimers({ now: Date.now() + 60 * 60_000, toFake: ['Date'] })
+    expect((await getRepairData({ vehicleId: 'v1', kind: 'labor' })).data).toEqual({
+      status: 'none',
+    })
+    expect(laborTimes).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('stored answers', () => {
+  it("keeps a paid key's answer, so the model year is never paid for twice", async () => {
+    laborTimes.mockResolvedValue(paid())
+    await open()
+    expect(table).toHaveLength(1)
+    expect(table[0]).toMatchObject({
+      source: 'openlaborproject',
+      owner: 'conn1',
+      connectionId: 'conn1',
+      kind: 'labor',
+      lookupKey: 'ford|f-150|2018',
+    })
+    clearRepairDataCache()
+    // Another vehicle of the same model year, written a little differently.
+    vehicle({ make: ' ford ', model: 'F-150 ', vin: '1FTFW1E50JFA99999' })
+    const view = await open()
+    expect(view.items).toHaveLength(4)
+    expect(laborTimes).toHaveBeenCalledTimes(1)
+  })
+
+  it('keeps nothing a free key fetched, and deletes what the key kept while it was paid', async () => {
+    laborTimes.mockResolvedValueOnce(paid())
+    await open()
+    expect(table).toHaveLength(1)
+    clearRepairDataCache()
+    vehicle({ model: 'F-250' })
+    laborTimes.mockResolvedValueOnce(free())
+    await open()
+    expect(table).toHaveLength(0)
+  })
+
+  it('keeps an answer only the VIN found under a digest of it, never the VIN', async () => {
+    const answer = { ...paidAnswer(), matchedBy: 'vin' as const }
+    laborTimes.mockResolvedValue(paid(answer))
+    vehicle({ model: 'F150', vin: '1FTFW1E50JFA12345' })
+    await open()
+    expect(table[0].lookupKey).toMatch(/^vin:[0-9a-f]{64}$/)
+    expect(JSON.stringify(table)).not.toContain('1FTFW1E50JFA12345')
+  })
+
+  it(`asks again once an answer is ${REFRESH_AFTER_DAYS} days old, and shows the old one if that fails`, async () => {
+    laborTimes.mockResolvedValueOnce(paid())
+    await open()
+    clearRepairDataCache()
+    table[0].fetchedAt = new Date(Date.now() - (REFRESH_AFTER_DAYS + 1) * 86_400_000)
+    laborTimes.mockRejectedValueOnce(new Error('Open Labor Project could not be reached'))
+    const view = await open()
+    expect(view.items).toHaveLength(4)
+    expect(laborTimes).toHaveBeenCalledTimes(2)
+    expect(ctx.log).toHaveBeenCalledWith(
+      'warn',
+      'Showing a stored answer: the refresh failed',
+      expect.anything()
+    )
+  })
+
+  it('deletes everything the key fetched when the key is refused', async () => {
+    laborTimes.mockResolvedValueOnce(paid())
+    await open()
+    clearRepairDataCache()
+    table[0].fetchedAt = new Date(Date.now() - (REFRESH_AFTER_DAYS + 1) * 86_400_000)
+    laborTimes.mockRejectedValueOnce(
+      new RepairDataKeyRejected('Open Labor Project rejected the API key')
+    )
+    const res = await getRepairData({ vehicleId: 'v1', kind: 'labor' })
+    expect(res.data).toEqual({
+      status: 'failed',
+      message: 'Open Labor Project rejected the API key',
+    })
+    expect(table).toHaveLength(0)
+  })
+
+  it('ignores a stored answer of an older shape', async () => {
+    laborTimes.mockResolvedValue(paid())
+    await open()
+    clearRepairDataCache()
+    table[0].version = 0
+    await open()
+    expect(laborTimes).toHaveBeenCalledTimes(2)
+  })
+})
+
+describe('the key the server holds', () => {
+  it('is used, and shared, only while the workshop has no key of its own', () => {
+    expect(usesServerKey(manifest, {}, {})).toBe(false)
+    expect(usesServerKey(manifest, {}, { [SERVER_KEY_ENV]: 'k' })).toBe(true)
+    expect(usesServerKey(manifest, { apiKey: 'own' }, { [SERVER_KEY_ENV]: 'k' })).toBe(false)
+  })
+
+  it('stores what it fetched for every workshop, with no connection to go with', async () => {
+    vi.stubEnv(SERVER_KEY_ENV, 'server-key')
+    ctx.credentials = {}
+    laborTimes.mockResolvedValue(paid())
+    await open()
+    expect(table[0]).toMatchObject({ owner: PLATFORM_OWNER, connectionId: null })
+  })
+
+  it("holds each workshop to a daily allowance of calls, counting only the source's", async () => {
+    vi.stubEnv(SERVER_KEY_ENV, 'server-key')
+    vi.stubEnv('REPAIR_DATA_SERVER_KEY_DAILY_LOOKUPS', '2')
+    ctx.credentials = {}
+    laborTimes.mockResolvedValue(free())
+    await open()
+    await open() // from memory: free
+    clearRepairDataCache()
+    await open()
+    clearRepairDataCache()
+    const res = await getRepairData({ vehicleId: 'v1', kind: 'labor' })
+    expect(res.data?.status).toBe('failed')
+    expect(laborTimes).toHaveBeenCalledTimes(2)
+  })
+})
+
+describe('unlockRepairDataItem', () => {
+  it('gets one job and keeps its hours for the next opening', async () => {
+    laborTimes.mockResolvedValueOnce(free())
+    await open()
+
+    const one = answerOf('labor-one-job')
+    // The same job for the 4WD variant the dialog opened on.
+    one.variants[0].vehicleId = '92a92bb0-7293-4783-97e9-743c15473972'
+    one.variants[0].drivetrain = '4wd'
+    laborTimes.mockResolvedValueOnce(free(one))
+    const variant = {
+      engineId: '5.0l-v8-coyote',
+      drivetrain: '4wd',
+      vehicleId: '92a92bb0-7293-4783-97e9-743c15473972',
+    }
+    const res = await unlockRepairDataItem({
+      vehicleId: 'v1',
+      kind: 'labor',
+      variant,
+      item: 'water-pump',
+    })
+    expect(res.data).toMatchObject({ status: 'found', value: { id: 'water-pump', hours: 2.8 } })
+    expect(laborTimes.mock.calls[1][1]).toMatchObject({ variant, item: 'water-pump' })
+
+    const again = await open()
+    expect(again.items.find((i) => i.id === 'water-pump')).toMatchObject({
+      hours: 2.8,
+      locked: false,
+    })
+    expect(laborTimes).toHaveBeenCalledTimes(2)
+  })
+
+  it("hides a value that comes back below the workshop's minimum confidence", async () => {
+    connected({ minConfidence: 'high' })
+    laborTimes.mockResolvedValueOnce(free())
+    await open()
+    const one = answerOf('labor-one-job') // water pump, rated medium
+    one.variants[0].vehicleId = '92a92bb0-7293-4783-97e9-743c15473972'
+    one.variants[0].drivetrain = '4wd'
+    laborTimes.mockResolvedValueOnce(free(one))
+    const res = await unlockRepairDataItem({
+      vehicleId: 'v1',
+      kind: 'labor',
+      variant: {
+        engineId: '5.0l-v8-coyote',
+        drivetrain: '4wd',
+        vehicleId: '92a92bb0-7293-4783-97e9-743c15473972',
+      },
+      item: 'water-pump',
+    })
+    expect(res.data).toEqual({ status: 'hidden' })
+    // Opened again, the shown value is left out and counted with the rest.
+    const again = await open()
+    expect(again.items.find((i) => i.id === 'water-pump')).toBeUndefined()
+    expect(again.hidden).toBeGreaterThan(0)
+  })
+
+  it('says so when the source would not unlock it', async () => {
+    laborTimes.mockResolvedValue(free(null))
+    const res = await unlockRepairDataItem({
+      vehicleId: 'v1',
+      kind: 'labor',
+      variant: { engineId: '5.0l-v8-coyote', drivetrain: '4wd', vehicleId: null },
+      item: 'water-pump',
+    })
+    expect(res.data).toEqual({ status: 'none' })
+  })
+})
+
+describe('the daily plan check', () => {
+  const plan = vi.fn<(c: ConnectorContext) => Promise<{ keep: boolean | null }>>()
+  const run = () => repairDataJobs(manifest, plan)[CHECK_PLAN_JOB](ctx, {})
+
+  async function storeOne() {
+    laborTimes.mockResolvedValueOnce(paid())
+    await open()
+    expect(table).toHaveLength(1)
+  }
+
+  it('spends no call while nothing is stored', async () => {
+    expect(await run()).toEqual({ summary: 'Nothing stored' })
+    expect(plan).not.toHaveBeenCalled()
+  })
+
+  it('keeps the stored answers while the plan allows it', async () => {
+    await storeOne()
+    plan.mockResolvedValueOnce({ keep: true })
+    await run()
+    expect(table).toHaveLength(1)
+  })
+
+  it('deletes them once the plan is free again', async () => {
+    await storeOne()
+    plan.mockResolvedValueOnce({ keep: false })
+    await run()
+    expect(table).toHaveLength(0)
+  })
+
+  it('deletes them when the key is refused', async () => {
+    await storeOne()
+    plan.mockRejectedValueOnce(new RepairDataKeyRejected('refused'))
+    await run()
+    expect(table).toHaveLength(0)
+  })
+
+  it('leaves them when the source would not say', async () => {
+    await storeOne()
+    plan.mockResolvedValueOnce({ keep: null })
+    await run()
+    expect(table).toHaveLength(1)
+  })
+
+  it("leaves the server key's answers to the lookups made with it", async () => {
+    vi.stubEnv(SERVER_KEY_ENV, 'server-key')
+    ctx.credentials = {}
+    expect(await run()).toEqual({ summary: 'Uses the server key' })
+    expect(plan).not.toHaveBeenCalled()
+  })
+})
+
+describe('matchVariant', () => {
+  const variants = answerOf('labor-model').variants
+
+  it('matches the displacement on file', () => {
+    expect(matchVariant(variants, { engineSize: '2.7 L' })).toEqual({ index: 0, match: 'engine' })
+    expect(matchVariant(variants, { engineSize: '5000 cc', fuelType: 'gasoline' })).toEqual({
+      index: 2,
+      match: 'engine',
+    })
+  })
+
+  it('prefers the diesel for a diesel', () => {
+    expect(matchVariant(variants, { fuelType: 'diesel' })).toEqual({ index: 1, match: 'fuel' })
+  })
+
+  it('says when no listed engine is the one on file', () => {
+    // A diesel with no diesel listed, as a European car looked up in US data.
+    const petrol = variants.filter((v) => !/diesel/i.test(v.engine))
+    expect(matchVariant(petrol, { fuelType: 'diesel' }).match).toBe('none')
+    expect(matchVariant(variants, { engineSize: '3.5 L' }).match).toBe('none')
+  })
+
+  it('keeps the first gasoline engine when nothing is known, and says so', () => {
+    expect(matchVariant(variants, {})).toEqual({ index: 0, match: 'unknown' })
+    expect(pickVariant(variants, {})).toBe(0)
+  })
+
+  it('reads litres from what the form holds', () => {
+    expect(litresOf('5.0 L')).toBe(5)
+    expect(litresOf('1,6')).toBe(1.6)
+    expect(litresOf('1998 cc')).toBeCloseTo(1.998)
+    expect(litresOf('')).toBeNull()
+  })
+})

+ 121 - 0
src/__tests__/features/integrations/repair-prefs.test.ts

@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest'
+import {
+  adjustsHours,
+  DEFAULT_PREFS,
+  hoursToAdd,
+  meetsConfidence,
+  readPrefs,
+} from '@/features/integrations/Lib/repair-prefs'
+import { withServerKeys } from '@/integrations/registry'
+import { manifest } from '@/integrations/openlaborproject/manifest'
+
+describe('readPrefs', () => {
+  it('fills in the defaults for a connection that saved nothing', () => {
+    expect(readPrefs({})).toEqual(DEFAULT_PREFS)
+    expect(readPrefs(null)).toEqual(DEFAULT_PREFS)
+  })
+
+  it('takes what the workshop saved', () => {
+    expect(
+      readPrefs({
+        minConfidence: 'high',
+        hoursFrom: 'high',
+        adjustPercent: 15,
+        rounding: '0.25',
+        fluids: false,
+        units: 'us',
+      })
+    ).toEqual({
+      minConfidence: 'high',
+      hoursFrom: 'high',
+      adjustPercent: 15,
+      rounding: '0.25',
+      fluids: false,
+      units: 'us',
+    })
+  })
+
+  it('keeps the adjustment within bounds and drops values it does not know', () => {
+    expect(readPrefs({ adjustPercent: 900 }).adjustPercent).toBe(200)
+    expect(readPrefs({ adjustPercent: -90 }).adjustPercent).toBe(-50)
+    expect(readPrefs({ adjustPercent: 'abc' }).adjustPercent).toBe(0)
+    expect(readPrefs({ rounding: '0.3', units: 'imperial' })).toMatchObject({
+      rounding: 'none',
+      units: 'both',
+    })
+  })
+})
+
+describe('hoursToAdd', () => {
+  const job = { hours: 1.2, high: 1.6 }
+
+  it('adds the book time as it is by default', () => {
+    expect(hoursToAdd(job, DEFAULT_PREFS)).toBe(1.2)
+    expect(adjustsHours(DEFAULT_PREFS)).toBe(false)
+  })
+
+  it('takes the top of the range when the workshop prefers it', () => {
+    expect(hoursToAdd(job, { ...DEFAULT_PREFS, hoursFrom: 'high' })).toBe(1.6)
+    // A job without a range falls back to its book time.
+    expect(hoursToAdd({ hours: 1.2, high: null }, { ...DEFAULT_PREFS, hoursFrom: 'high' })).toBe(
+      1.2
+    )
+  })
+
+  it('adjusts, then rounds up to the step', () => {
+    expect(hoursToAdd(job, { ...DEFAULT_PREFS, adjustPercent: 10 })).toBe(1.32)
+    expect(hoursToAdd(job, { ...DEFAULT_PREFS, adjustPercent: 10, rounding: '0.1' })).toBe(1.4)
+    expect(hoursToAdd(job, { ...DEFAULT_PREFS, rounding: '0.25' })).toBe(1.25)
+    expect(hoursToAdd(job, { ...DEFAULT_PREFS, rounding: '0.5' })).toBe(1.5)
+  })
+
+  it('leaves a value already on the step where it is', () => {
+    expect(hoursToAdd({ hours: 1.2, high: null }, { ...DEFAULT_PREFS, rounding: '0.1' })).toBe(1.2)
+    expect(hoursToAdd({ hours: 0.3, high: null }, { ...DEFAULT_PREFS, rounding: '0.1' })).toBe(0.3)
+  })
+
+  it('has nothing to add for a job the key has not unlocked', () => {
+    expect(hoursToAdd({ hours: null, high: null }, DEFAULT_PREFS)).toBeNull()
+  })
+})
+
+describe('meetsConfidence', () => {
+  it('ranks the levels from estimate up to OEM', () => {
+    const at = (confidence: 'oem' | 'high' | 'medium' | 'estimated' | null) => ({
+      confidence,
+      locked: false,
+    })
+    expect(meetsConfidence(at('estimated'), 'all')).toBe(true)
+    expect(meetsConfidence(at('estimated'), 'medium')).toBe(false)
+    expect(meetsConfidence(at('medium'), 'medium')).toBe(true)
+    expect(meetsConfidence(at('medium'), 'high')).toBe(false)
+    expect(meetsConfidence(at('oem'), 'high')).toBe(true)
+    expect(meetsConfidence(at('high'), 'oem')).toBe(false)
+    expect(meetsConfidence(at(null), 'medium')).toBe(false)
+  })
+
+  it('keeps a value the key has not shown yet, since it has no level to judge', () => {
+    expect(meetsConfidence({ confidence: null, locked: true }, 'oem')).toBe(true)
+  })
+})
+
+describe('withServerKeys', () => {
+  const field = () => (manifest.auth.type === 'api-key' ? manifest.auth.fields[0] : (null as never))
+
+  it('asks every workshop for its own key while the server holds none', () => {
+    expect(withServerKeys(manifest, {})).toBe(manifest)
+    expect(field()).toMatchObject({ required: true, help: 'apiKeyHelp' })
+  })
+
+  it("makes the workshop's key optional once the server holds one", () => {
+    const out = withServerKeys(manifest, { OPEN_LABOR_PROJECT_API_KEY: 'server-key' })
+    const fields = out.auth.type === 'api-key' ? out.auth.fields : []
+    expect(fields[0]).toMatchObject({ required: false, help: 'apiKeyServerHelp' })
+    // The manifest itself is left alone.
+    expect(field().required).toBe(true)
+  })
+
+  it('treats a blank variable as unset', () => {
+    expect(withServerKeys(manifest, { OPEN_LABOR_PROJECT_API_KEY: '  ' })).toBe(manifest)
+  })
+})

+ 97 - 0
src/__tests__/features/vehicles/fluid-part-button.test.tsx

@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import type { StockPart } from '@/features/integrations/Lib/fluid-stock'
+import type { FluidSpec } from '@/features/integrations/Lib/types'
+import { FluidPartButton } from '@/features/vehicles/Components/labor-guide/FluidPartButton'
+
+vi.mock('@/components/currency-settings-context', () => ({
+  useFormatCurrency: () => (n: number) => `${n.toFixed(2)} kr`,
+}))
+
+const OIL: FluidSpec = {
+  id: 'engine_oil',
+  system: 'Engine Oil',
+  fluid: '0W-20 full synthetic',
+  spec: 'API SP',
+  capacityLiters: 5.7,
+  capacityQuarts: 6,
+  drainRefillLiters: 5.2,
+  drainRefillQuarts: 5.5,
+  intervalKm: null,
+  intervalMiles: null,
+  notes: null,
+  confidence: 'high',
+  locked: false,
+}
+
+function stockPart(id: string, name: string): StockPart {
+  return { id, name, partNumber: null, unit: 'l', unitCost: 100, sellPrice: 189, quantity: 24 }
+}
+
+describe('FluidPartButton', () => {
+  it('adds a plain line in one click when nothing in stock matches', () => {
+    const onAdd = vi.fn()
+    render(
+      <FluidPartButton
+        fluid={OIL}
+        units="both"
+        stock={{ parts: [stockPart('pads', 'Bremseklosser')] }}
+        added={false}
+        onAdd={onAdd}
+      />
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Add Engine Oil as a part' }))
+    expect(onAdd).toHaveBeenCalledWith(
+      expect.objectContaining({
+        name: 'Engine Oil, 0W-20 full synthetic (API SP)',
+        quantity: 5.2,
+        unit: 'l',
+        inventoryPartId: null,
+      })
+    )
+  })
+
+  it('offers the matching stock item with its price, stock and the amount it adds', () => {
+    const onAdd = vi.fn()
+    render(
+      <FluidPartButton
+        fluid={OIL}
+        units="both"
+        stock={{ parts: [stockPart('oil', 'Motorolje 0W-20')] }}
+        added={false}
+        onAdd={onAdd}
+      />
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Add Engine Oil as a part' }))
+    expect(screen.getByText('Takes 5.2 L at a drain and refill')).toBeInTheDocument()
+    expect(screen.getByText('From stock')).toBeInTheDocument()
+    expect(screen.getByText(/189\.00 kr \/ l · 24 l in stock/)).toBeInTheDocument()
+    fireEvent.click(screen.getByRole('button', { name: /Motorolje 0W-20/ }))
+    expect(onAdd).toHaveBeenCalledWith(
+      expect.objectContaining({ inventoryPartId: 'oil', quantity: 5.2, unitPrice: 189 })
+    )
+  })
+
+  it('still adds a plain line when the shop would rather not use stock', () => {
+    const onAdd = vi.fn()
+    render(
+      <FluidPartButton
+        fluid={OIL}
+        units="us"
+        stock={{ parts: [stockPart('oil', 'Motorolje 0W-20')] }}
+        added={false}
+        onAdd={onAdd}
+      />
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Add Engine Oil as a part' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Add without a stock item' }))
+    expect(onAdd).toHaveBeenCalledWith(
+      expect.objectContaining({ inventoryPartId: null, unit: 'qt', quantity: 5.49 })
+    )
+  })
+
+  it('says when the fluid is already on the job', () => {
+    render(<FluidPartButton fluid={OIL} units="both" added onAdd={vi.fn()} />)
+    expect(screen.getByRole('button', { name: 'Added' })).toBeDisabled()
+  })
+})

+ 134 - 0
src/__tests__/features/vehicles/labor-times-panel.test.tsx

@@ -0,0 +1,134 @@
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { DEFAULT_PREFS, type RepairDataPrefs } from '@/features/integrations/Lib/repair-prefs'
+import type { LaborTime } from '@/features/integrations/Lib/types'
+import {
+  type LaborView,
+  LaborTimesPanel,
+} from '@/features/vehicles/Components/labor-guide/LaborTimesPanel'
+
+vi.mock('next/link', () => ({
+  default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
+    <a href={href} {...rest}>
+      {children}
+    </a>
+  ),
+}))
+
+function job(
+  id: string,
+  name: string,
+  category: string,
+  extra: Partial<LaborTime> = {}
+): LaborTime {
+  return {
+    id,
+    name,
+    category,
+    hours: 1.2,
+    low: 1,
+    high: 1.6,
+    confidence: 'high',
+    locked: false,
+    ...extra,
+  }
+}
+
+function view(over: Partial<LaborView> = {}, prefs: Partial<RepairDataPrefs> = {}): LaborView {
+  return {
+    connector: 'openlaborproject',
+    source: 'Open Labor Project',
+    logo: null,
+    region: 'US',
+    variants: [
+      { ref: { engineId: '5.0l-v8', drivetrain: '4wd', vehicleId: null }, label: '5.0L V8, 4WD' },
+    ],
+    selected: 0,
+    match: 'engine',
+    items: [
+      job('water-pump', 'Water Pump', 'cooling'),
+      job('thermostat', 'Thermostat', 'cooling', { confidence: 'estimated' }),
+      job('pads-front', 'Brake Pads - Front', 'brakes'),
+      job('alternator', 'Alternator', 'electrical', {
+        hours: null,
+        low: null,
+        high: null,
+        confidence: null,
+        locked: true,
+      }),
+    ],
+    hidden: 0,
+    prefs: { ...DEFAULT_PREFS, ...prefs },
+    ...over,
+  }
+}
+
+function renderPanel(v: LaborView, onAdd = vi.fn(), onUnlock = vi.fn()) {
+  render(
+    <TooltipProvider>
+      <LaborTimesPanel
+        view={v}
+        settingsHref="/settings/integrations/openlaborproject"
+        added={new Set()}
+        unlocking={new Set()}
+        onAdd={onAdd}
+        onUnlock={onUnlock}
+      />
+    </TooltipProvider>
+  )
+  return { onAdd, onUnlock }
+}
+
+describe('LaborTimesPanel', () => {
+  it('groups jobs under their category and counts them', () => {
+    renderPanel(view())
+    expect(screen.getByText('4 jobs')).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'Cooling' })).toBeInTheDocument()
+    expect(screen.getByRole('heading', { name: 'Brakes' })).toBeInTheDocument()
+  })
+
+  it('adds the hours the workshop settings work out, not the raw book time', () => {
+    const { onAdd } = renderPanel(view({}, { adjustPercent: 10, rounding: '0.1' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Add Water Pump as a labor line' }))
+    expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: 'water-pump' }), 1.4)
+    // The book time stays in view beside the adjusted figure.
+    expect(screen.getAllByText('Book 1.2 h').length).toBeGreaterThan(0)
+  })
+
+  it('narrows the list as the person types, and says when nothing matches', () => {
+    renderPanel(view())
+    const search = screen.getByRole('searchbox', { name: 'Search jobs, such as brake pads' })
+    fireEvent.change(search, { target: { value: 'pump' } })
+    expect(screen.getByText('1 job')).toBeInTheDocument()
+    expect(screen.queryByText('Thermostat')).not.toBeInTheDocument()
+    fireEvent.change(search, { target: { value: 'zzz' } })
+    expect(screen.getByText('No jobs match “zzz”')).toBeInTheDocument()
+    fireEvent.click(screen.getByRole('button', { name: 'Show all jobs' }))
+    expect(screen.getByText('4 jobs')).toBeInTheDocument()
+  })
+
+  it('shows one category from the rail', () => {
+    renderPanel(view())
+    const rail = screen.getByRole('navigation', { name: 'Job categories' })
+    fireEvent.click(within(rail).getByRole('button', { name: /Brakes/ }))
+    expect(screen.getByText('1 job')).toBeInTheDocument()
+    expect(screen.queryByText('Water Pump')).not.toBeInTheDocument()
+  })
+
+  it('offers to show the hours of a job the key has not unlocked', () => {
+    const { onUnlock } = renderPanel(view())
+    fireEvent.click(screen.getByRole('button', { name: 'Show hours' }))
+    expect(onUnlock).toHaveBeenCalledWith(expect.objectContaining({ id: 'alternator' }))
+  })
+
+  it('warns when the engine may not be the one on file, and says what the settings hide', () => {
+    renderPanel(view({ match: 'none', hidden: 3 }))
+    expect(screen.getByRole('alert')).toHaveTextContent('None of the engines listed matches')
+    expect(screen.getByText(/3 times below your minimum confidence are hidden/)).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: 'Change' })).toHaveAttribute(
+      'href',
+      '/settings/integrations/openlaborproject'
+    )
+  })
+})

+ 1 - 0
src/__tests__/lib/files/references.test.ts

@@ -21,6 +21,7 @@ import { allReferencedSuffixes, FILE_REFERENCES, referencedSuffixes } from '@/li
 const NOT_FILES: Record<string, string> = {
   'AuditLog.metadata': 'history of what happened; never a live use of a file',
   'DashboardWidget.config': 'widget settings',
+  'RepairDataCache.data': 'labor times and fluid specs from a repair data source',
   'DocumentDesign.documentType': 'invoice or quote',
   'DocumentDesign.layout': 'section order and switches; the logo is in `template`',
   'DocumentDesignSnapshot.layout': 'the same, frozen',

+ 10 - 2
src/__tests__/setup.ts

@@ -57,9 +57,11 @@ vi.mock('next-intl', () => ({
     const file = parts[0]
     const sub = parts.slice(1).join('.')
 
+    // A namespace can reach any depth, such as "service.laborGuide.confidence".
     let messages: unknown = allMessages[file]
-    if (sub && messages && typeof messages === 'object') {
-      messages = (messages as Record<string, unknown>)[sub]
+    for (const seg of sub ? sub.split('.') : []) {
+      if (!messages || typeof messages !== 'object') break
+      messages = (messages as Record<string, unknown>)[seg]
     }
 
     const t = (key: string, values?: Record<string, unknown>) => {
@@ -79,6 +81,12 @@ vi.mock('next-intl', () => ({
     return t
   },
   useLocale: () => 'en',
+  useFormatter: () => ({
+    number: (value: number, options?: Intl.NumberFormatOptions) =>
+      new Intl.NumberFormat('en', options).format(value),
+    dateTime: (value: Date | number, options?: Intl.DateTimeFormatOptions) =>
+      new Intl.DateTimeFormat('en', options).format(value),
+  }),
   useMessages: () => ({}),
   NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
 }))

+ 1 - 0
src/app/(authenticated)/settings/integrations/integrations-catalog.tsx

@@ -27,6 +27,7 @@ const CATEGORY_ORDER: IntegrationCategory[] = [
   'automation',
   'storage',
   'registry',
+  'repair',
   'other',
 ]
 

+ 244 - 0
src/features/integrations/Actions/repairDataActions.ts

@@ -0,0 +1,244 @@
+'use server'
+
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { publicErrorMessage } from '@/lib/public-error-message'
+import { withAuth } from '@/lib/with-auth'
+import {
+  askRepairData,
+  askRepairDataItem,
+  findRepairDataConnection,
+  matchVariant,
+  type RepairDataItem,
+  type RepairDataKind,
+  sameVariant,
+  type VariantMatch,
+} from '../Lib/repair-data'
+import { meetsConfidence, type RepairDataPrefs, readPrefs } from '../Lib/repair-prefs'
+import type { RepairDataVariantRef } from '../Lib/types'
+import { withinLookupBudget } from '../Lib/vehicle-lookup'
+
+/**
+ * The labor guide on work orders and quotes: book times to add as labor
+ * lines, and fluid capacities to read, for the job's vehicle.
+ */
+
+const READ_VEHICLES = [{ action: PermissionAction.READ, subject: PermissionSubject.VEHICLES }]
+
+const kindSchema = z.enum(['labor', 'fluids'])
+
+/** Ids the source hands out: slugs such as "5.0l-v8-coyote" and uuids. */
+const sourceId = z
+  .string()
+  .trim()
+  .min(1)
+  .max(120)
+  .regex(/^[A-Za-z0-9._-]+$/)
+
+const variantSchema = z.object({
+  engineId: sourceId,
+  drivetrain: sourceId.nullable(),
+  vehicleId: sourceId.nullable(),
+})
+
+export interface RepairDataVariantOption {
+  ref: RepairDataVariantRef
+  /** Engine and drivetrain, such as "5.0L V8 Coyote, 4WD". */
+  label: string
+}
+
+export interface RepairDataView<K extends RepairDataKind> {
+  /** The connector that answered, such as "openlaborproject", for the link to its settings. */
+  connector: string
+  /** Source name for the attribution line. */
+  source: string
+  /** The source's logo under /public. */
+  logo: string | null
+  /** The market the values are for, as an ISO country code, such as "US". */
+  region: string | null
+  variants: RepairDataVariantOption[]
+  /** Index into variants of the one whose items are below. */
+  selected: number
+  /**
+   * How well the selected engine fits the vehicle on file, or "chosen" when
+   * the person picked it in the dialog.
+   */
+  match: VariantMatch | 'chosen'
+  items: RepairDataItem<K>[]
+  /** Values left out because they are below the workshop's minimum confidence. */
+  hidden: number
+  prefs: RepairDataPrefs
+}
+
+/**
+ * What a lookup came to. A source that has no such vehicle, or a key that
+ * has used its calls, is an answer for the dialog to show, not an error for
+ * the server log.
+ */
+export type RepairDataOutcome<T> =
+  | { status: 'found'; value: T }
+  | { status: 'none' }
+  | { status: 'failed'; message: string }
+
+function failed(err: unknown): { status: 'failed'; message: string } {
+  return { status: 'failed', message: publicErrorMessage(err) }
+}
+
+/** Which parts of the labor guide to offer: plan on, and a connected source for each. */
+export async function isRepairDataAvailable() {
+  return withAuth(
+    async ({ organizationId }): Promise<Record<RepairDataKind, boolean>> => {
+      const features = await getFeatures(organizationId)
+      if (!features.integrations) return { labor: false, fluids: false }
+      const [labor, fluids] = await Promise.all([
+        findRepairDataConnection(organizationId, 'labor'),
+        findRepairDataConnection(organizationId, 'fluids'),
+      ])
+      return {
+        labor: labor !== null,
+        fluids: fluids !== null && readPrefs(fluids.settings).fluids,
+      }
+    },
+    { requiredPermissions: READ_VEHICLES }
+  )
+}
+
+async function prepare(organizationId: string, vehicleId: string, kind: RepairDataKind) {
+  const features = await getFeatures(organizationId)
+  if (!features.integrations) throw new Error('Integrations are not included in your plan')
+  const connection = await findRepairDataConnection(organizationId, kind)
+  if (!connection) throw new Error('No labor guide is connected')
+  if (kind === 'fluids' && !readPrefs(connection.settings).fluids)
+    throw new Error('Fluid specs are turned off in the integration settings')
+  const vehicle = await db.vehicle.findFirst({
+    where: { id: vehicleId, organizationId },
+    select: { make: true, model: true, year: true, vin: true, engineSize: true, fuelType: true },
+  })
+  if (!vehicle) throw new Error('Vehicle not found')
+  if (!vehicle.make.trim() || !vehicle.model.trim() || !vehicle.year)
+    throw new Error('The vehicle needs a make, model and year first')
+  if (!withinLookupBudget(organizationId))
+    throw new Error('Too many lookups, wait a minute and try again')
+  const query = {
+    make: vehicle.make,
+    model: vehicle.model,
+    year: vehicle.year,
+    vin: vehicle.vin ?? undefined,
+  }
+  return { connection, vehicle, query }
+}
+
+function labelOf(engine: string, drivetrain: string | null): string {
+  return drivetrain ? `${engine}, ${drivetrain.toUpperCase()}` : engine
+}
+
+/**
+ * Everything the guide lists for the vehicle, for one engine and drivetrain:
+ * the one asked for, else the one most like the vehicle on file.
+ */
+export async function getRepairData(input: {
+  vehicleId: string
+  kind: RepairDataKind
+  variant?: RepairDataVariantRef
+}) {
+  return withAuth(
+    async ({ organizationId }): Promise<RepairDataOutcome<RepairDataView<RepairDataKind>>> => {
+      const parsed = z
+        .object({
+          vehicleId: z.string().min(1).max(64),
+          kind: kindSchema,
+          variant: variantSchema.optional(),
+        })
+        .parse(input)
+      let looked: Awaited<ReturnType<typeof askRepairData>>
+      let vehicle: Awaited<ReturnType<typeof prepare>>['vehicle']
+      try {
+        const prepared = await prepare(organizationId, parsed.vehicleId, parsed.kind)
+        vehicle = prepared.vehicle
+        looked = await askRepairData(prepared.connection.id, parsed.kind, prepared.query)
+      } catch (err) {
+        return failed(err)
+      }
+      const { answer, connector, source, logo, prefs } = looked
+      if (!answer) return { status: 'none' }
+      const asked = parsed.variant
+        ? answer.variants.findIndex((v) => sameVariant(v, parsed.variant as RepairDataVariantRef))
+        : -1
+      const fit = matchVariant(answer.variants, vehicle)
+      const selected = asked >= 0 ? asked : fit.index
+      const all = answer.variants[selected].items
+      // The minimum confidence is for labor times, the figure that is billed.
+      const items =
+        parsed.kind === 'labor' ? all.filter((i) => meetsConfidence(i, prefs.minConfidence)) : all
+      return {
+        status: 'found',
+        value: {
+          connector,
+          source,
+          logo,
+          region: answer.region ?? null,
+          variants: answer.variants.map((v) => ({
+            ref: { engineId: v.engineId, drivetrain: v.drivetrain, vehicleId: v.vehicleId },
+            label: labelOf(v.engine, v.drivetrain),
+          })),
+          selected,
+          match: asked >= 0 ? 'chosen' : fit.match,
+          items,
+          hidden: all.length - items.length,
+          prefs,
+        },
+      }
+    },
+    { requiredPermissions: READ_VEHICLES }
+  )
+}
+
+/**
+ * What unlocking one value came to. "hidden" is a labor time the source
+ * rates below the workshop's minimum confidence: a locked row has no level
+ * to judge until it is shown, so the setting can only apply once it is.
+ */
+export type RepairDataUnlock<T> = RepairDataOutcome<T> | { status: 'hidden' }
+
+/**
+ * The value of one locked job or fluid system, for a key that unlocks them
+ * one at a time.
+ */
+export async function unlockRepairDataItem(input: {
+  vehicleId: string
+  kind: RepairDataKind
+  variant: RepairDataVariantRef
+  item: string
+}) {
+  return withAuth(
+    async ({ organizationId }): Promise<RepairDataUnlock<RepairDataItem<RepairDataKind>>> => {
+      const parsed = z
+        .object({
+          vehicleId: z.string().min(1).max(64),
+          kind: kindSchema,
+          variant: variantSchema,
+          item: sourceId,
+        })
+        .parse(input)
+      try {
+        const { connection, query } = await prepare(organizationId, parsed.vehicleId, parsed.kind)
+        const item = await askRepairDataItem(connection.id, parsed.kind, {
+          ...query,
+          variant: parsed.variant,
+          item: parsed.item,
+        })
+        if (!item) return { status: 'none' }
+        const { minConfidence } = readPrefs(connection.settings)
+        if (parsed.kind === 'labor' && !meetsConfidence(item, minConfidence)) {
+          return { status: 'hidden' }
+        }
+        return { status: 'found', value: item }
+      } catch (err) {
+        return failed(err)
+      }
+    },
+    { requiredPermissions: READ_VEHICLES }
+  )
+}

+ 264 - 0
src/features/integrations/Lib/fluid-stock.ts

@@ -0,0 +1,264 @@
+import {
+  lineTotal,
+  type PricingSettings,
+  resolvePartPrice,
+} from '@/features/inventory/Lib/partPricing'
+import type { FluidUnits } from './repair-prefs'
+import type { FluidSpec } from './types'
+
+/**
+ * A fluid from the labor guide as a part line: matched against the
+ * workshop's stock when it carries the same grade or spec, else a plain line
+ * for the shop to price. Pure, and free of server imports, so the dialog and
+ * the tests share it.
+ *
+ * Matching goes by grades and spec codes ("0W-20", "DOT 4", "VW 504.00",
+ * "G13") rather than by words: they read the same in every language, so a
+ * Norwegian shop's "Motorolje 0W-20 5L" meets Open Labor Project's
+ * "0W-20 full synthetic" without any translation.
+ */
+
+/** A stocked item, as both the work order and the quote page hold them. */
+export interface StockPart {
+  id: string
+  name: string
+  partNumber: string | null
+  description?: string | null
+  category?: string | null
+  unit?: string | null
+  unitCost: number
+  sellPrice: number
+  /** In stock, in `unit`. */
+  quantity: number
+}
+
+/** A part line the page adds as it would a pick from stock. */
+export interface FluidPartLine {
+  partNumber: string
+  name: string
+  quantity: number
+  unit: string | null
+  category: string | null
+  unitCost: number
+  markupPercent: number
+  unitPrice: number
+  total: number
+  inventoryPartId: string | null
+}
+
+/**
+ * Grades and spec codes, each with its kind. Two values of an exclusive
+ * kind cannot both be right: an oil is 0W-20 or 5W-30, never both, so a
+ * stocked item with another value of that kind is not offered at all.
+ */
+/**
+ * VW's coolant family as one value per generation: G12, G12+, G12++ and
+ * G12 evo are different coolants, and "plus" is written both ways.
+ */
+function coolantValue(raw: string): string {
+  const m = /g\s*-?\s*(1[1-3])\s*(.*)$/.exec(raw)
+  if (!m) return raw.replace(/[\s-]/g, '')
+  const suffix = m[2].replace(/\s+/g, '').replace(/plus/g, '+')
+  return `g${m[1]}${suffix}`
+}
+
+const PATTERNS: {
+  kind: string
+  exclusive: boolean
+  pattern: RegExp
+  /** How a match is written for comparing; spacing, dashes and plus signs dropped unless given. */
+  value?: (raw: string) => string
+}[] = [
+  { kind: 'viscosity', exclusive: true, pattern: /\b\d{1,2}\s*w\s*-?\s*\d{2,3}\b/g },
+  { kind: 'brake', exclusive: true, pattern: /\bdot\s*[345](?:\.1)?\b/g },
+  {
+    kind: 'coolant',
+    exclusive: true,
+    // The longest suffix, then nothing that could carry it on: "g12++" is
+    // never read as g12 or g12+.
+    pattern: /\bg\s*-?\s*1[1-3](?:\s*(?:\+\s*\+|\+|plus\s*plus|plus|evo))?(?=$|[^a-z0-9+])/g,
+    value: coolantValue,
+  },
+  { kind: 'spec', exclusive: false, pattern: /\bdexos\s*-?\s*\d?\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\bdexron\s*-?\s*(?:vi|v|iii|ii)\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\bmercon\s*-?\s*(?:ulv|lv|sp|v)\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\batf\s*\+\s*4\b/g },
+  {
+    kind: 'spec',
+    exclusive: false,
+    pattern: /\b(?:vw|mb|bmw)\s*-?\s*(?:ll\s*-?\s*)?\d{2,3}(?:\.\d{1,2})?\b/g,
+  },
+  { kind: 'spec', exclusive: false, pattern: /\b\d{3}\.\d{1,2}\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\bapi\s*(?:s[a-z]|c[a-z](?:-\d)?|gl\s*-?\s*\d)\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\bilsac\s*gf\s*-?\s*\d[ab]?\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\bacea\s*[abce]\d\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\b(?:p\s*-?\s*hoat|si\s*-?\s*oat|hoat|oat|iat)\b/g },
+  { kind: 'spec', exclusive: false, pattern: /\bcvt\b/g },
+]
+
+interface Token {
+  kind: string
+  exclusive: boolean
+  value: string
+}
+
+/** The grades and spec codes in a piece of text, written one way: "0W-20" and "0w20" are both 0w20. */
+export function fluidTokens(text: string | null | undefined): Token[] {
+  if (!text) return []
+  const plain = text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
+  const out = new Map<string, Token>()
+  for (const { kind, exclusive, pattern, value: write } of PATTERNS) {
+    for (const m of plain.matchAll(pattern)) {
+      const value = write ? write(m[0]) : m[0].replace(/[\s\-+]/g, '')
+      out.set(`${kind}:${value}`, { kind, exclusive, value })
+    }
+  }
+  return [...out.values()]
+}
+
+export interface StockMatch {
+  part: StockPart
+  /** How many of the fluid's grades and specs the item carries. */
+  score: number
+}
+
+/**
+ * Stocked items that carry the fluid's grade or spec, best first: the most
+ * codes in common, then in stock, then sold by volume. An item with a
+ * different grade, brake fluid class or coolant type is left out.
+ */
+export function matchStock(
+  fluid: Pick<FluidSpec, 'fluid' | 'spec'>,
+  stock: readonly StockPart[],
+  limit = 3
+): StockMatch[] {
+  const wanted = fluidTokens(`${fluid.fluid ?? ''} ${fluid.spec ?? ''}`)
+  if (wanted.length === 0) return []
+  const wantedKeys = new Set(wanted.map((t) => `${t.kind}:${t.value}`))
+  const exclusiveKinds = new Set(wanted.filter((t) => t.exclusive).map((t) => t.kind))
+  const matches: StockMatch[] = []
+  for (const part of stock) {
+    const own = fluidTokens(
+      [part.name, part.partNumber, part.description, part.category].filter(Boolean).join(' ')
+    )
+    const clash = own.some(
+      (t) => t.exclusive && exclusiveKinds.has(t.kind) && !wantedKeys.has(`${t.kind}:${t.value}`)
+    )
+    if (clash) continue
+    const score = own.filter((t) => wantedKeys.has(`${t.kind}:${t.value}`)).length
+    if (score > 0) matches.push({ part, score })
+  }
+  return matches
+    .sort(
+      (a, b) =>
+        b.score - a.score ||
+        Number(b.part.quantity > 0) - Number(a.part.quantity > 0) ||
+        Number(volumeUnitOf(b.part.unit) !== null) - Number(volumeUnitOf(a.part.unit) !== null) ||
+        a.part.name.localeCompare(b.part.name)
+    )
+    .slice(0, limit)
+}
+
+const LITRES_PER_QUART = 0.946353
+
+/** "l", "Ltr", "liter", "qt", "ml": the volume a unit stands for, or null for "pcs" and the like. */
+export function volumeUnitOf(unit: string | null | undefined): 'l' | 'ml' | 'qt' | null {
+  const u = unit?.trim().toLowerCase().replace(/\.$/, '') ?? ''
+  if (['l', 'lt', 'ltr', 'liter', 'litre', 'liters', 'litres', 'liter(s)'].includes(u)) return 'l'
+  if (['ml', 'milliliter', 'millilitre'].includes(u)) return 'ml'
+  if (['qt', 'quart', 'quarts', 'qts'].includes(u)) return 'qt'
+  return null
+}
+
+/**
+ * What a service puts in: the drain and refill amount when the source gives
+ * one, which is what goes back in at a change, else the full capacity. Both
+ * in litres, from whichever figure the source has.
+ */
+export function fluidLitres(
+  fluid: Pick<
+    FluidSpec,
+    'drainRefillLiters' | 'drainRefillQuarts' | 'capacityLiters' | 'capacityQuarts'
+  >
+): { litres: number; from: 'drainRefill' | 'capacity' } | null {
+  const drain = fluid.drainRefillLiters ?? toLitres(fluid.drainRefillQuarts)
+  if (drain !== null) return { litres: drain, from: 'drainRefill' }
+  const capacity = fluid.capacityLiters ?? toLitres(fluid.capacityQuarts)
+  return capacity !== null ? { litres: capacity, from: 'capacity' } : null
+}
+
+function toLitres(quarts: number | null): number | null {
+  return quarts === null ? null : quarts * LITRES_PER_QUART
+}
+
+function round2(n: number): number {
+  return Math.round(n * 100) / 100
+}
+
+/** The amount in the given unit, or null when the unit is not a volume. */
+export function amountIn(litres: number, unit: string | null | undefined): number | null {
+  const volume = volumeUnitOf(unit)
+  if (volume === 'l') return round2(litres)
+  if (volume === 'ml') return Math.round(litres * 1000)
+  if (volume === 'qt') return round2(litres / LITRES_PER_QUART)
+  return null
+}
+
+/** "Engine Oil, 0W-20 full synthetic (API SP)": what a plain line is called. */
+export function fluidLineName(fluid: Pick<FluidSpec, 'system' | 'fluid' | 'spec'>): string {
+  const what = [fluid.system, fluid.fluid].filter(Boolean).join(', ')
+  return fluid.spec ? `${what} (${fluid.spec})` : what
+}
+
+/**
+ * The part line for a fluid. From stock, it is that item at the page's own
+ * price, in the item's unit when it is sold by volume and one of it when it
+ * is not (a 5 L can). Without a stock item, it is a plain line with the
+ * amount in litres, or quarts when the workshop reads fluids in US units,
+ * and no price yet.
+ */
+export function fluidPartLine(
+  fluid: Pick<
+    FluidSpec,
+    | 'system'
+    | 'fluid'
+    | 'spec'
+    | 'drainRefillLiters'
+    | 'drainRefillQuarts'
+    | 'capacityLiters'
+    | 'capacityQuarts'
+  >,
+  options: { units: FluidUnits; part?: StockPart; pricing?: PricingSettings }
+): FluidPartLine {
+  const needed = fluidLitres(fluid)
+  const { part, pricing } = options
+  if (part) {
+    const quantity = (needed && amountIn(needed.litres, part.unit)) || 1
+    const { unitPrice, markupPercent } = resolvePartPrice(part, pricing)
+    return {
+      partNumber: part.partNumber ?? '',
+      name: part.name,
+      quantity,
+      unit: part.unit ?? null,
+      category: part.category ?? null,
+      unitCost: Number(part.unitCost) || 0,
+      markupPercent,
+      unitPrice,
+      total: lineTotal(quantity, unitPrice),
+      inventoryPartId: part.id,
+    }
+  }
+  const unit = options.units === 'us' ? 'qt' : 'l'
+  return {
+    partNumber: '',
+    name: fluidLineName(fluid),
+    quantity: (needed && amountIn(needed.litres, unit)) || 1,
+    unit: needed ? unit : null,
+    category: null,
+    unitCost: 0,
+    markupPercent: 0,
+    unitPrice: 0,
+    total: 0,
+    inventoryPartId: null,
+  }
+}

+ 7 - 0
src/features/integrations/Lib/repair-data-contract.ts

@@ -0,0 +1,7 @@
+/**
+ * Names a repair data connector's manifest needs, kept apart from
+ * repair-data.ts so that importing a manifest never pulls in the database.
+ */
+
+/** The daily check on stored answers, scheduled by each repair data connector. */
+export const CHECK_PLAN_JOB = 'repair.checkPlan'

+ 598 - 0
src/features/integrations/Lib/repair-data.ts

@@ -0,0 +1,598 @@
+import { createHash } from 'node:crypto'
+import { getTranslations } from 'next-intl/server'
+import { connectorName } from '@/features/integrations/Lib/display-name'
+import { getManifest } from '@/integrations/registry'
+import { db } from '@/lib/db'
+import { loadConnection } from './connections'
+import { CHECK_PLAN_JOB } from './repair-data-contract'
+import { type RepairDataPrefs, readPrefs } from './repair-prefs'
+import {
+  type ConnectorContext,
+  type ConnectorManifest,
+  type FluidSpec,
+  type JobHandler,
+  type LaborTime,
+  type RepairDataAnswer,
+  RepairDataKeyRejected,
+  type RepairDataQuery,
+  type RepairDataReply,
+  type RepairDataVariant,
+  type RepairDataVariantRef,
+} from './types'
+
+/**
+ * Labor times and fluid capacities from whichever repair data source the
+ * workshop has connected. Actions ask here rather than knowing about
+ * connectors, so a second source is a new folder under src/integrations.
+ *
+ * Answers are kept at two levels:
+ * - in memory for ten minutes, for every key, so reopening a work order or
+ *   switching engine in the dialog never asks again;
+ * - in the database for as long as the key's plan lets it keep what it
+ *   fetched (Open Labor Project's paid plans), so a vehicle looked up once
+ *   is not paid for again. Those rows are deleted as soon as a reply or the
+ *   daily check says the plan is free again, when the key is refused, and
+ *   with the connection when the workshop disconnects.
+ *
+ * Whose key fetched an answer decides who it serves. A workshop's own key
+ * serves that workshop only. The key the server holds, for a service
+ * Torqvoice pays for (CredentialField.serverKeyEnv), serves every workshop
+ * that uses it, which is what keeps one shared key affordable.
+ */
+
+export { CHECK_PLAN_JOB }
+
+export type RepairDataKind = 'labor' | 'fluids'
+
+export const REPAIR_CAPABILITY: Record<RepairDataKind, string> = {
+  labor: 'repair.labor',
+  fluids: 'repair.fluids',
+}
+
+export type RepairDataItem<K extends RepairDataKind> = K extends 'labor' ? LaborTime : FluidSpec
+
+/** Owner of the answers fetched with the key the server holds. */
+export const PLATFORM_OWNER = 'platform'
+/** Bumped when the stored shape or a mapping rule changes; older rows are fetched again. */
+export const REPAIR_DATA_VERSION = 1
+/**
+ * A stored answer older than this is fetched again the next time the guide
+ * opens on it, so values the source has since verified replace estimates.
+ * When that fetch fails the stored answer is shown instead.
+ */
+export const REFRESH_AFTER_DAYS = 30
+/** Lookups a day one workshop may make on the server's key, unless the environment says otherwise. */
+const SERVER_KEY_DAILY_LOOKUPS = 40
+
+/** The newest active connection that answers this kind of question. */
+export async function findRepairDataConnection(
+  organizationId: string,
+  kind: RepairDataKind
+): Promise<{ id: string; connectorId: string; settings: Record<string, unknown> } | null> {
+  const rows = await db.integrationConnection.findMany({
+    where: { organizationId, status: 'active' },
+    select: { id: true, connectorId: true, settings: true },
+    orderBy: { createdAt: 'desc' },
+  })
+  const pick = rows.find((r) =>
+    getManifest(r.connectorId)?.capabilities.includes(REPAIR_CAPABILITY[kind])
+  )
+  if (!pick) return null
+  return { ...pick, settings: (pick.settings as Record<string, unknown>) ?? {} }
+}
+
+/**
+ * Whether a connection uses the key the server holds: its own key field is
+ * empty and the server has one under the field's variable.
+ */
+export function usesServerKey(
+  manifest: ConnectorManifest,
+  credentials: Record<string, unknown>,
+  env: Record<string, string | undefined> = process.env
+): boolean {
+  if (manifest.auth.type !== 'api-key') return false
+  return manifest.auth.fields.some(
+    (f) =>
+      Boolean(f.serverKeyEnv && env[f.serverKeyEnv]?.trim()) &&
+      !String(credentials[f.key] ?? '').trim()
+  )
+}
+
+interface Account {
+  /** The connection id, or PLATFORM_OWNER for the server's key. */
+  owner: string
+  /** Set for a workshop's own key, so its stored rows go with the connection. */
+  connectionId: string | null
+}
+
+function accountOf(ctx: ConnectorContext, manifest: ConnectorManifest): Account {
+  return usesServerKey(manifest, ctx.credentials)
+    ? { owner: PLATFORM_OWNER, connectionId: null }
+    : { owner: ctx.connection.id, connectionId: ctx.connection.id }
+}
+
+/** "ford|f-150|2018", as the workshop wrote them, so spacing and case never split a model. */
+function modelKeyOf(q: Pick<RepairDataQuery, 'make' | 'model' | 'year'>): string {
+  const squeeze = (s: string) => s.trim().replace(/\s+/g, ' ').toLowerCase()
+  return `${squeeze(q.make)}|${squeeze(q.model)}|${q.year}`
+}
+
+/** The VIN as a digest: a stored row says which vehicle it was for without holding the VIN. */
+function vinKeyOf(vin: string | undefined): string | null {
+  const v = vin?.trim().toUpperCase()
+  if (!v) return null
+  return `vin:${createHash('sha256').update(v).digest('hex')}`
+}
+
+// ---- memory, ten minutes, every key ----------------------------------------
+
+const MEMORY_TTL_MS = 10 * 60_000
+/**
+ * A vehicle the source has nothing for is remembered longer: asking again
+ * costs a call, and an unknown vehicle can take it half a minute to say no.
+ */
+const MEMORY_NONE_TTL_MS = 6 * 60 * 60_000
+const MEMORY_MAX = 200
+const memory = new Map<string, { answer: RepairDataAnswer<unknown> | null; expiresAt: number }>()
+
+function memoryKey(owner: string, kind: RepairDataKind, q: RepairDataQuery): string {
+  return [owner, kind, modelKeyOf(q), vinKeyOf(q.vin) ?? ''].join('|')
+}
+
+function fromMemory<K extends RepairDataKind>(key: string) {
+  const hit = memory.get(key)
+  if (!hit) return undefined
+  if (hit.expiresAt <= Date.now()) {
+    memory.delete(key)
+    return undefined
+  }
+  return hit.answer as RepairDataAnswer<RepairDataItem<K>> | null
+}
+
+function remember(key: string, answer: RepairDataAnswer<unknown> | null) {
+  if (memory.size >= MEMORY_MAX) {
+    const oldest = memory.keys().next().value
+    if (oldest !== undefined) memory.delete(oldest)
+  }
+  memory.set(key, {
+    answer,
+    expiresAt: Date.now() + (answer ? MEMORY_TTL_MS : MEMORY_NONE_TTL_MS),
+  })
+}
+
+function forgetMemory(owner: string) {
+  for (const key of memory.keys()) if (key.startsWith(`${owner}|`)) memory.delete(key)
+}
+
+/** For tests: start each case with nothing remembered. */
+export function clearRepairDataCache() {
+  memory.clear()
+}
+
+// ---- the database, while the plan allows it --------------------------------
+
+interface Stored<T> {
+  id: string
+  answer: RepairDataAnswer<T>
+  fetchedAt: Date
+}
+
+function isAnswer(value: unknown): value is RepairDataAnswer<unknown> {
+  return (
+    !!value &&
+    typeof value === 'object' &&
+    Array.isArray((value as RepairDataAnswer<unknown>).variants)
+  )
+}
+
+async function readStored<K extends RepairDataKind>(
+  source: string,
+  owner: string,
+  kind: K,
+  q: RepairDataQuery
+): Promise<Stored<RepairDataItem<K>> | null> {
+  const modelKey = modelKeyOf(q)
+  const vinKey = vinKeyOf(q.vin)
+  const rows = await db.repairDataCache.findMany({
+    where: {
+      source,
+      owner,
+      kind,
+      version: REPAIR_DATA_VERSION,
+      lookupKey: { in: vinKey ? [modelKey, vinKey] : [modelKey] },
+    },
+    select: { id: true, lookupKey: true, data: true, fetchedAt: true },
+  })
+  // The model's answer serves every vehicle of it, so it wins over a VIN's.
+  const row = rows.find((r) => r.lookupKey === modelKey) ?? rows[0]
+  if (!row || !isAnswer(row.data)) return null
+  return {
+    id: row.id,
+    answer: row.data as unknown as RepairDataAnswer<RepairDataItem<K>>,
+    fetchedAt: row.fetchedAt,
+  }
+}
+
+function isFresh(fetchedAt: Date, now = Date.now()): boolean {
+  return now - fetchedAt.getTime() < REFRESH_AFTER_DAYS * 86_400_000
+}
+
+/**
+ * What the reply means for stored answers: kept under the model, or under
+ * the VIN when only the VIN found it, while the plan allows it; every one of
+ * the owner's rows deleted once it does not.
+ */
+async function settleStored(
+  source: string,
+  account: Account,
+  kind: RepairDataKind,
+  q: RepairDataQuery,
+  reply: RepairDataReply<unknown>
+) {
+  if (!reply.keep) {
+    await forgetStored(source, account.owner)
+    return
+  }
+  if (!reply.answer) return
+  const vinKey = vinKeyOf(q.vin)
+  const lookupKey = reply.answer.matchedBy === 'vin' && vinKey ? vinKey : modelKeyOf(q)
+  const data = reply.answer as unknown as object
+  await db.repairDataCache.upsert({
+    where: { source_owner_kind_lookupKey: { source, owner: account.owner, kind, lookupKey } },
+    create: {
+      source,
+      owner: account.owner,
+      connectionId: account.connectionId,
+      kind,
+      lookupKey,
+      version: REPAIR_DATA_VERSION,
+      data,
+    },
+    update: { version: REPAIR_DATA_VERSION, data, fetchedAt: new Date() },
+  })
+}
+
+/**
+ * Every answer the owner's key stored. Memory stays: showing an answer for
+ * ten minutes is allowed on every plan. Returns how many rows went.
+ */
+export async function forgetStored(source: string, owner: string): Promise<number> {
+  const { count } = await db.repairDataCache.deleteMany({ where: { source, owner } })
+  return count
+}
+
+/** A refused key: nothing it fetched is shown again, stored or remembered. */
+async function forgetEverything(source: string, owner: string): Promise<number> {
+  forgetMemory(owner)
+  return forgetStored(source, owner)
+}
+
+/**
+ * What a connector's connection test learned about the key, applied to the
+ * answers it fetched: a refused key loses all of them, a key back on a free
+ * plan loses the stored ones. The test runs when the workshop connects or
+ * presses Test, which also covers a connection the daily check no longer
+ * reaches because a refused key put it in error.
+ */
+export async function settleKeyCheck(
+  ctx: ConnectorContext,
+  manifest: ConnectorManifest,
+  outcome: 'rejected' | 'free' | 'paid'
+): Promise<void> {
+  if (outcome === 'paid') return
+  const { owner } = accountOf(ctx, manifest)
+  if (outcome === 'rejected') await forgetEverything(manifest.id, owner)
+  else await forgetStored(manifest.id, owner)
+}
+
+/**
+ * The name the source already found this vehicle under, from the other
+ * kind of data or from the same kind's list, so the next question uses it
+ * instead of guessing again and does not fall back to the VIN.
+ */
+async function knownModelId(
+  source: string,
+  owner: string,
+  kinds: RepairDataKind[],
+  q: RepairDataQuery
+): Promise<string | undefined> {
+  for (const kind of kinds) {
+    const remembered = fromMemory(memoryKey(owner, kind, q))
+    if (remembered?.modelId) return remembered.modelId
+  }
+  for (const kind of kinds) {
+    const stored = await readStored(source, owner, kind, q)
+    if (stored?.answer.modelId) return stored.answer.modelId
+  }
+  return undefined
+}
+
+// ---- the server's key: a daily allowance per workshop ----------------------
+
+function serverKeyAllowance(env: Record<string, string | undefined> = process.env): number {
+  const n = Number(env.REPAIR_DATA_SERVER_KEY_DAILY_LOOKUPS)
+  return Number.isInteger(n) && n > 0 ? n : SERVER_KEY_DAILY_LOOKUPS
+}
+
+/**
+ * One lookup off the workshop's allowance on the server's key, counted in
+ * the connection's state per UTC day. Only lookups that reach the source
+ * count; an answer from memory or the database is free.
+ */
+async function spendServerKeyLookup(ctx: ConnectorContext) {
+  const day = new Date().toISOString().slice(0, 10)
+  const used = ctx.connection.state.serverKeyLookups as { day?: string; count?: number } | undefined
+  const count = used?.day === day ? (used.count ?? 0) : 0
+  if (count >= serverKeyAllowance()) {
+    throw new Error("Today's labor guide lookups are used up. More are available tomorrow.")
+  }
+  await ctx.saveState({ serverKeyLookups: { day, count: count + 1 } })
+}
+
+// ---- asking ----------------------------------------------------------------
+
+export interface RepairDataResult<K extends RepairDataKind> {
+  answer: RepairDataAnswer<RepairDataItem<K>> | null
+  /** The connector that answered, such as "openlaborproject", for the link to its settings. */
+  connector: string
+  /** Source name for the attribution line, such as "Open Labor Project". */
+  source: string
+  /** The source's logo under /public, for the dialog's header. */
+  logo: string | null
+  prefs: RepairDataPrefs
+}
+
+async function sourceName(connectorId: string): Promise<string> {
+  const manifest = getManifest(connectorId)
+  if (!manifest) return connectorId
+  const ti = await getTranslations('integrations')
+  return connectorName(ti, manifest)
+}
+
+function askerFor<K extends RepairDataKind>(
+  server: { laborTimes?: unknown; fluidSpecs?: unknown },
+  kind: K
+) {
+  const ask = (kind === 'labor' ? server.laborTimes : server.fluidSpecs) as
+    | ((ctx: ConnectorContext, q: RepairDataQuery) => Promise<RepairDataReply<RepairDataItem<K>>>)
+    | undefined
+  if (!ask) throw new Error('This integration cannot look this up')
+  return ask
+}
+
+/**
+ * Everything the source lists for one model year: from memory, else from
+ * the database while it is fresh, else from the source. A stale stored
+ * answer is shown when the source cannot be reached to refresh it.
+ */
+export async function askRepairData<K extends RepairDataKind>(
+  connectionId: string,
+  kind: K,
+  query: RepairDataQuery
+): Promise<RepairDataResult<K>> {
+  const { ctx, server } = await loadConnection(connectionId)
+  const manifest = server.manifest
+  const account = accountOf(ctx, manifest)
+  const base = {
+    connector: manifest.id,
+    source: await sourceName(manifest.id),
+    logo: getManifest(manifest.id)?.logo ?? null,
+    prefs: readPrefs(ctx.connection.settings),
+  }
+  const memKey = memoryKey(account.owner, kind, query)
+  const hit = fromMemory<K>(memKey)
+  if (hit !== undefined) return { ...base, answer: hit }
+
+  const stored = await readStored(manifest.id, account.owner, kind, query)
+  if (stored && isFresh(stored.fetchedAt)) {
+    remember(memKey, stored.answer)
+    return { ...base, answer: stored.answer }
+  }
+
+  const ask = askerFor(server, kind)
+  const other: RepairDataKind = kind === 'labor' ? 'fluids' : 'labor'
+  const modelId =
+    stored?.answer.modelId ?? (await knownModelId(manifest.id, account.owner, [other], query))
+  try {
+    if (account.owner === PLATFORM_OWNER) await spendServerKeyLookup(ctx)
+    const reply = await ask(ctx, { ...query, modelId, variant: undefined, item: undefined })
+    await settleStored(manifest.id, account, kind, query, reply)
+    remember(memKey, reply.answer)
+    return { ...base, answer: reply.answer }
+  } catch (err) {
+    if (err instanceof RepairDataKeyRejected) {
+      await forgetEverything(manifest.id, account.owner)
+      throw err
+    }
+    if (stored) {
+      await ctx.log('warn', 'Showing a stored answer: the refresh failed', {
+        kind,
+        error: err instanceof Error ? err.message : String(err),
+      })
+      remember(memKey, stored.answer)
+      return { ...base, answer: stored.answer }
+    }
+    throw err
+  }
+}
+
+export function sameVariant(a: RepairDataVariantRef, b: RepairDataVariantRef): boolean {
+  if (a.vehicleId && b.vehicleId) return a.vehicleId === b.vehicleId
+  return a.engineId === b.engineId && (a.drivetrain ?? null) === (b.drivetrain ?? null)
+}
+
+function withItem<T extends { id: string }>(
+  answer: RepairDataAnswer<T>,
+  variant: RepairDataVariantRef,
+  item: T
+): boolean {
+  const v = answer.variants.find((x) => sameVariant(x, variant))
+  if (!v) return false
+  v.items = v.items.map((i) => (i.id === item.id ? item : i))
+  return true
+}
+
+/**
+ * One job or fluid system on its own, for a key that lists everything but
+ * unlocks values one at a time. The value is written into the remembered
+ * answer, and into the stored one while the plan allows keeping it, so it is
+ * still there when the dialog opens again.
+ */
+export async function askRepairDataItem<K extends RepairDataKind>(
+  connectionId: string,
+  kind: K,
+  query: RepairDataQuery & { variant: RepairDataVariantRef; item: string }
+): Promise<RepairDataItem<K> | null> {
+  const { ctx, server } = await loadConnection(connectionId)
+  const manifest = server.manifest
+  const account = accountOf(ctx, manifest)
+  const ask = askerFor(server, kind)
+  const other: RepairDataKind = kind === 'labor' ? 'fluids' : 'labor'
+  const modelId = await knownModelId(manifest.id, account.owner, [kind, other], query)
+  let reply: RepairDataReply<RepairDataItem<K>>
+  try {
+    if (account.owner === PLATFORM_OWNER) await spendServerKeyLookup(ctx)
+    reply = await ask(ctx, { ...query, modelId })
+  } catch (err) {
+    if (err instanceof RepairDataKeyRejected) await forgetEverything(manifest.id, account.owner)
+    throw err
+  }
+  if (!reply.keep) await forgetStored(manifest.id, account.owner)
+  const found =
+    reply.answer?.variants.flatMap((v) => v.items).find((i) => i.id === query.item && !i.locked) ??
+    null
+  if (!found) return null
+
+  const remembered = fromMemory<K>(memoryKey(account.owner, kind, query))
+  if (remembered) withItem(remembered, query.variant, found)
+  if (reply.keep) {
+    const stored = await readStored(manifest.id, account.owner, kind, query)
+    if (stored && withItem(stored.answer, query.variant, found)) {
+      await db.repairDataCache.update({
+        where: { id: stored.id },
+        data: { data: stored.answer as unknown as object },
+      })
+    }
+  }
+  return found
+}
+
+// ---- the daily check -------------------------------------------------------
+
+/**
+ * The job each repair data connector schedules daily: while the workshop's
+ * own key has answers stored, ask the source whether its plan still allows
+ * keeping them, and delete them when it does not or the key is refused. A
+ * connection with nothing stored, or on the server's key, spends no call:
+ * Torqvoice's own subscription is checked by every lookup made with it.
+ */
+export function repairDataJobs(
+  manifest: ConnectorManifest,
+  plan: (ctx: ConnectorContext) => Promise<{ keep: boolean | null }>
+): Record<string, JobHandler> {
+  return {
+    [CHECK_PLAN_JOB]: async (ctx) => {
+      const account = accountOf(ctx, manifest)
+      if (account.owner === PLATFORM_OWNER) return { summary: 'Uses the server key' }
+      const count = await db.repairDataCache.count({
+        where: { source: manifest.id, owner: account.owner },
+      })
+      if (count === 0) return { summary: 'Nothing stored' }
+      let keep: boolean | null
+      try {
+        keep = (await plan(ctx)).keep
+      } catch (err) {
+        if (!(err instanceof RepairDataKeyRejected)) throw err
+        const gone = await forgetEverything(manifest.id, account.owner)
+        await ctx.log('warn', 'The key was refused, so the stored lookups were deleted', { gone })
+        return { summary: `Key refused: ${gone} stored lookups deleted` }
+      }
+      if (keep === false) {
+        const gone = await forgetStored(manifest.id, account.owner)
+        await ctx.log('info', 'The plan no longer allows keeping lookups; deleted them', { gone })
+        return { summary: `Plan is free now: ${gone} stored lookups deleted` }
+      }
+      return {
+        summary: keep
+          ? `${count} stored lookups kept`
+          : 'The source did not say; checked again tomorrow',
+      }
+    },
+  }
+}
+
+// ---- matching the vehicle --------------------------------------------------
+
+/** Litres from what the vehicle form holds: "5.0 L", "2.7", "1998 cc". */
+export function litresOf(engineSize: string | null | undefined): number | null {
+  if (!engineSize) return null
+  const m = /(\d+(?:[.,]\d+)?)/.exec(engineSize)
+  if (!m) return null
+  const n = Number(m[1].replace(',', '.'))
+  if (!Number.isFinite(n) || n <= 0) return null
+  return n >= 100 ? n / 1000 : n
+}
+
+/**
+ * How well the engine the guide opens on fits the vehicle on file:
+ * - engine: same displacement;
+ * - fuel: same fuel, and the vehicle has no displacement to compare;
+ * - none: the vehicle has an engine size or fuel on file and no listed
+ *   engine has it, so the times may be for a different engine;
+ * - unknown: the vehicle has neither on file.
+ */
+export type VariantMatch = 'engine' | 'fuel' | 'none' | 'unknown'
+
+function fuelOf(engine: string): 'diesel' | 'electric' | 'hybrid' | 'gasoline' {
+  const label = engine.toLowerCase()
+  if (/diesel|tdi|power ?stroke|duramax|cummins|ecodiesel|crdi|hdi|dci/.test(label)) return 'diesel'
+  if (/hybrid|phev/.test(label)) return 'hybrid'
+  if (/electric|\bev\b|kwh/.test(label)) return 'electric'
+  return 'gasoline'
+}
+
+/**
+ * The variant most like the vehicle on file, and how well it fits: same
+ * displacement first, then the same fuel. A tie keeps the source's order.
+ */
+export function matchVariant<T>(
+  variants: RepairDataVariant<T>[],
+  vehicle: { engineSize?: string | null; fuelType?: string | null }
+): { index: number; match: VariantMatch } {
+  const litres = litresOf(vehicle.engineSize)
+  const raw = vehicle.fuelType?.trim().toLowerCase() ?? ''
+  const fuel =
+    raw === 'diesel' || raw === 'electric' || raw === 'hybrid' || raw === 'gasoline' ? raw : null
+  let best = 0
+  let bestScore = -1
+  let bestSize = false
+  let bestFuel = false
+  variants.forEach((v, i) => {
+    const own = litresOf(/(\d+(?:\.\d+)?)\s*l\b/i.exec(v.engine)?.[1] ?? null)
+    const size = litres !== null && own !== null && Math.abs(own - litres) < 0.05
+    const sameFuel = fuel !== null && fuelOf(v.engine) === fuel
+    // Without a fuel on file a gasoline engine is the likelier one.
+    const score =
+      (size ? 4 : 0) + (sameFuel ? 2 : 0) + (!fuel && fuelOf(v.engine) === 'gasoline' ? 1 : 0)
+    if (score > bestScore) {
+      best = i
+      bestScore = score
+      bestSize = size
+      bestFuel = sameFuel
+    }
+  })
+  let match: VariantMatch
+  if (litres === null && fuel === null) match = 'unknown'
+  else if (bestSize && (fuel === null || bestFuel)) match = 'engine'
+  else if (litres === null && bestFuel) match = 'fuel'
+  else match = 'none'
+  return { index: best, match }
+}
+
+/** The index alone, for callers that only need the pick. */
+export function pickVariant<T>(
+  variants: RepairDataVariant<T>[],
+  vehicle: { engineSize?: string | null; fuelType?: string | null }
+): number {
+  return matchVariant(variants, vehicle).index
+}

+ 108 - 0
src/features/integrations/Lib/repair-prefs.ts

@@ -0,0 +1,108 @@
+import type { LaborTime, RepairDataConfidence } from './types'
+
+/**
+ * How a workshop has set up its labor guide, read from the connection's
+ * settings. Shared by the server, which filters what the dialog is sent, and
+ * by the dialog, which works out the hours a job adds. No server imports.
+ */
+
+export const MIN_CONFIDENCE = ['all', 'medium', 'high', 'oem'] as const
+export const HOURS_FROM = ['book', 'high'] as const
+export const ROUNDING = ['none', '0.1', '0.25', '0.5'] as const
+export const FLUID_UNITS = ['both', 'metric', 'us'] as const
+
+export type MinConfidence = (typeof MIN_CONFIDENCE)[number]
+export type HoursFrom = (typeof HOURS_FROM)[number]
+export type Rounding = (typeof ROUNDING)[number]
+export type FluidUnits = (typeof FLUID_UNITS)[number]
+
+export interface RepairDataPrefs {
+  /** The lowest confidence a labor time needs to be listed. */
+  minConfidence: MinConfidence
+  /** Which figure a job adds: the book time, or the top of its range. */
+  hoursFrom: HoursFrom
+  /** Added to every time, in percent: 10 adds a tenth, -10 takes one off. */
+  adjustPercent: number
+  /** The step a job's hours are rounded up to, or none. */
+  rounding: Rounding
+  /** Whether the guide has a fluids tab. */
+  fluids: boolean
+  /** Which units fluid capacities and intervals are shown in. */
+  units: FluidUnits
+}
+
+export const DEFAULT_PREFS: RepairDataPrefs = {
+  minConfidence: 'all',
+  hoursFrom: 'book',
+  adjustPercent: 0,
+  rounding: 'none',
+  fluids: true,
+  units: 'both',
+}
+
+/** The adjustment a workshop can set: from half the book time to three times it. */
+export const ADJUST_LIMITS = { min: -50, max: 200 } as const
+
+function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
+  return typeof value === 'string' && (allowed as readonly string[]).includes(value)
+    ? (value as T)
+    : fallback
+}
+
+/** The connection's settings as prefs; anything missing or out of range falls back to the default. */
+export function readPrefs(settings: Record<string, unknown> | null | undefined): RepairDataPrefs {
+  const s = settings ?? {}
+  const adjust = Number(s.adjustPercent)
+  return {
+    minConfidence: oneOf(s.minConfidence, MIN_CONFIDENCE, DEFAULT_PREFS.minConfidence),
+    hoursFrom: oneOf(s.hoursFrom, HOURS_FROM, DEFAULT_PREFS.hoursFrom),
+    adjustPercent: Number.isFinite(adjust)
+      ? Math.min(ADJUST_LIMITS.max, Math.max(ADJUST_LIMITS.min, adjust))
+      : DEFAULT_PREFS.adjustPercent,
+    rounding: oneOf(s.rounding, ROUNDING, DEFAULT_PREFS.rounding),
+    fluids: s.fluids === undefined ? DEFAULT_PREFS.fluids : s.fluids !== false,
+    units: oneOf(s.units, FLUID_UNITS, DEFAULT_PREFS.units),
+  }
+}
+
+const RANK: Record<RepairDataConfidence, number> = { estimated: 0, medium: 1, high: 2, oem: 3 }
+
+/**
+ * Whether a value is sure enough to list. A value the key has not unlocked
+ * yet has no confidence to judge, so it stays listed until it is shown.
+ */
+export function meetsConfidence(
+  item: { confidence: RepairDataConfidence | null; locked: boolean },
+  min: MinConfidence
+): boolean {
+  if (min === 'all' || item.locked) return true
+  if (!item.confidence) return false
+  return RANK[item.confidence] >= RANK[min]
+}
+
+/**
+ * The hours a job adds as a labor line: the book time or the top of its
+ * range, adjusted, then rounded up to the workshop's step. Null while the
+ * key has not unlocked the job.
+ */
+export function hoursToAdd(
+  job: Pick<LaborTime, 'hours' | 'high'>,
+  prefs: Pick<RepairDataPrefs, 'hoursFrom' | 'adjustPercent' | 'rounding'>
+): number | null {
+  const base = prefs.hoursFrom === 'high' && job.high !== null ? job.high : job.hours
+  if (base === null) return null
+  let hours = base * (1 + prefs.adjustPercent / 100)
+  if (prefs.rounding !== 'none') {
+    const step = Number(prefs.rounding)
+    // The small allowance keeps 1.2 at 1.2 instead of floating up to 1.3.
+    hours = Math.ceil(hours / step - 1e-9) * step
+  }
+  return Math.round(hours * 100) / 100
+}
+
+/** Whether the hours a job adds differ from its book time, so the dialog shows both. */
+export function adjustsHours(
+  prefs: Pick<RepairDataPrefs, 'hoursFrom' | 'adjustPercent' | 'rounding'>
+): boolean {
+  return prefs.hoursFrom !== 'book' || prefs.adjustPercent !== 0 || prefs.rounding !== 'none'
+}

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

@@ -22,6 +22,7 @@ export type IntegrationCategory =
   | 'automation'
   | 'storage'
   | 'registry'
+  | 'repair'
   | 'other'
 
 export type ConnectionStatus = 'pending' | 'active' | 'error' | 'disconnected'
@@ -79,6 +80,16 @@ export interface CredentialField {
    * it, and the page says what is still missing until it is entered.
    */
   deferrable?: boolean
+  /**
+   * The name of an environment variable that can hold a key the server uses
+   * for every workshop, for a service Torqvoice pays for on the workshop's
+   * behalf. While it is set, the registry makes the field optional and
+   * swaps in `serverKeyHelp`: a workshop that leaves the field empty uses
+   * the server's key, one that fills it in uses its own.
+   */
+  serverKeyEnv?: string
+  /** i18n key with the help shown instead of `help` while the server holds a key. */
+  serverKeyHelp?: string
 }
 
 /**
@@ -449,6 +460,109 @@ export interface VehicleSafetyReport {
   url: string
 }
 
+/**
+ * One engine and drivetrain of a model, as a repair data source tells them
+ * apart. Engine and drivetrain are the guide's own ids, passed back to ask
+ * about that variant alone.
+ */
+export interface RepairDataVariantRef {
+  engineId: string
+  drivetrain: string | null
+  /** The guide's id for this exact variant, when it has one. */
+  vehicleId: string | null
+}
+
+/**
+ * What a repair data source is asked about: the model, and the VIN when the
+ * vehicle has one. `variant` narrows to one engine and drivetrain, `item` to
+ * one job or fluid system, for a key that unlocks values one at a time.
+ */
+export interface RepairDataQuery {
+  make: string
+  model: string
+  year: number
+  vin?: string
+  /**
+   * The source's own id for the model, from an earlier answer for this
+   * vehicle, so it is asked under the name that worked and not guessed again.
+   */
+  modelId?: string
+  variant?: RepairDataVariantRef
+  item?: string
+}
+
+/** How sure the source is of a value: checked against the maker's data, verified, likely, or a guess. */
+export type RepairDataConfidence = 'oem' | 'high' | 'medium' | 'estimated'
+
+/** A job's book time. */
+export interface LaborTime {
+  /** The source's id for the job, to ask for it alone. */
+  id: string
+  name: string
+  category: string
+  /** Book hours; null while the key does not unlock this job. */
+  hours: number | null
+  low: number | null
+  high: number | null
+  confidence: RepairDataConfidence | null
+  /** The key lists the job but not its hours; ask for it alone to get them. */
+  locked: boolean
+}
+
+/** What one system takes, and how much. */
+export interface FluidSpec {
+  /** The source's id for the system, to ask for it alone. */
+  id: string
+  system: string
+  fluid: string | null
+  spec: string | null
+  capacityLiters: number | null
+  capacityQuarts: number | null
+  drainRefillLiters: number | null
+  drainRefillQuarts: number | null
+  intervalKm: number | null
+  intervalMiles: number | null
+  notes: string | null
+  confidence: RepairDataConfidence | null
+  locked: boolean
+}
+
+export interface RepairDataVariant<T> extends RepairDataVariantRef {
+  /** The engine as the source names it, such as "5.0L V8 Coyote". */
+  engine: string
+  items: T[]
+}
+
+/** What a source lists for one model year, per engine and drivetrain. */
+export interface RepairDataAnswer<T> {
+  make: string
+  model: string
+  variants: RepairDataVariant<T>[]
+  /**
+   * Whether the model name found it, or only the VIN did. A model-name
+   * answer serves every vehicle of that model year; a VIN answer is kept
+   * for that one vehicle.
+   */
+  matchedBy?: 'model' | 'vin'
+  /** The source's own id for the model, passed back to ask about the vehicle again. */
+  modelId?: string
+  /** The market the values are for, as an ISO country code, such as "US". */
+  region?: string
+}
+
+/**
+ * A source's answer, and whether the account's plan lets it keep what it
+ * fetched. Open Labor Project lets a paid plan keep its answers for as long
+ * as it stays subscribed; a free key may only show them.
+ */
+export interface RepairDataReply<T> {
+  answer: RepairDataAnswer<T> | null
+  keep: boolean
+}
+
+/** The key was refused: revoked, mistyped, or its subscription has ended. */
+export class RepairDataKeyRejected extends Error {}
+
 export interface ConnectorServer {
   manifest: ConnectorManifest
   /** After connecting: who the account is, shown on the connection page. */
@@ -500,6 +614,22 @@ export interface ConnectorServer {
    * the authority has no record of the model.
    */
   vehicleSafety?(ctx: ConnectorContext, query: VehicleSafetyQuery): Promise<VehicleSafetyReport>
+  /**
+   * Book times for a model year, for connectors with the 'repair.labor'
+   * capability. Synchronous because a dialog is waiting on it. The answer is
+   * null when the source has no such vehicle. Throws RepairDataKeyRejected
+   * when the key is refused.
+   */
+  laborTimes?(ctx: ConnectorContext, query: RepairDataQuery): Promise<RepairDataReply<LaborTime>>
+  /** Fluid types and capacities, for connectors with the 'repair.fluids' capability. */
+  fluidSpecs?(ctx: ConnectorContext, query: RepairDataQuery): Promise<RepairDataReply<FluidSpec>>
+  /**
+   * Whether the key's plan still lets the account keep what it fetched, for
+   * the daily check on stored answers. Null when the source would not say,
+   * such as a key that has used its calls for the day. Throws
+   * RepairDataKeyRejected when the key is refused.
+   */
+  repairDataPlan?(ctx: ConnectorContext): Promise<{ keep: boolean | null }>
   jobs: Record<string, JobHandler>
   /** Inbound webhook handling, where the vendor pushes. */
   webhook?: {

+ 24 - 0
src/features/quotes/Components/QuoteLaborEditor.tsx

@@ -13,6 +13,10 @@ import { ShopFeeTag, useShopFeeLocked } from '@/features/settings/Components/Sho
 import { LineTaxToggle } from '@/features/settings/Components/LineTaxToggle'
 import { useLineTax } from '@/features/settings/Components/LineTaxContext'
 import { laborTaxable } from '@/lib/taxable-lines'
+import { LaborGuideButton } from '@/features/vehicles/Components/labor-guide/LaborGuideButton'
+import type { LaborGuideJob } from '@/features/vehicles/Components/labor-guide/LaborGuideDialog'
+import type { LaborGuideStock } from '@/features/vehicles/Components/labor-guide/FluidPartButton'
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
 
 const QuoteLaborRow = memo(function QuoteLaborRow({
   labor,
@@ -145,6 +149,13 @@ interface QuoteLaborEditorProps {
   onAddService?: () => void
   hasPresets?: boolean
   onOpenPresets?: () => void
+  /** The quote's vehicle, for the labor guide; none until one is picked. */
+  vehicleId?: string | null
+  onAddGuideJob?: (job: LaborGuideJob) => void
+  /** Adds a fluid from the labor guide to the quote's parts. */
+  onAddGuidePart?: (line: FluidPartLine) => void
+  /** The workshop's stock, for the labor guide to match fluids against. */
+  guideStock?: LaborGuideStock
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
 }
@@ -160,6 +171,10 @@ export const QuoteLaborEditor = memo(function QuoteLaborEditor({
   onAddService,
   hasPresets,
   onOpenPresets,
+  vehicleId,
+  onAddGuideJob,
+  onAddGuidePart,
+  guideStock,
   t,
 }: QuoteLaborEditorProps) {
   const formatCurrency = useFormatCurrency()
@@ -175,6 +190,15 @@ export const QuoteLaborEditor = memo(function QuoteLaborEditor({
                 {t('labor.fromPresets')}
               </Button>
             )}
+            {vehicleId && onAddGuideJob && (
+              <LaborGuideButton
+                vehicleId={vehicleId}
+                onAdd={onAddGuideJob}
+                onAddPart={onAddGuidePart}
+                stock={guideStock}
+                labelled
+              />
+            )}
             <Button type="button" variant="outline" size="sm" onClick={onAdd}>
               <Plus className="mr-1 h-3.5 w-3.5" /> {t('labor.addLabor')}
             </Button>

+ 34 - 1
src/features/quotes/Components/QuotePageClient.tsx

@@ -14,7 +14,7 @@ import { TAX_APPLIES_TO_ALL } from '@/lib/taxable-lines'
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setQuoteEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import type { LockState } from '@/lib/document-lock'
-import { useState, useCallback, useEffect } from 'react'
+import { useState, useCallback, useEffect, useMemo } from 'react'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
@@ -69,6 +69,8 @@ import { QuoteRightColumn } from './QuoteRightColumn'
 import { QuoteCustomerResponse } from './QuoteCustomerResponse'
 import { VehicleCombobox } from './VehicleCombobox'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
+import type { LaborGuideJob } from '@/features/vehicles/Components/labor-guide/LaborGuideDialog'
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
 import type { WarrantyTexts } from '@/lib/warranty'
 
 const LG_BREAKPOINT = 1024
@@ -212,6 +214,33 @@ export function QuotePageClient({
     [defaultLaborRate, state]
   )
 
+  // A job from the labor guide: its name and book hours at the workshop's rate.
+  const handleAddGuideJob = useCallback(
+    (job: LaborGuideJob) => {
+      state.addLaborBulk([
+        {
+          description: job.description,
+          hours: job.hours,
+          rate: defaultLaborRate,
+          total: job.hours * defaultLaborRate,
+          pricingType: 'hourly',
+          excluded: false,
+        },
+      ])
+    },
+    [defaultLaborRate, state]
+  )
+
+  // A fluid from the labor guide: a stocked item at the quote's stock price,
+  // the way a pick from stock is priced here, or a plain line to price.
+  const handleAddGuidePart = useCallback(
+    (line: FluidPartLine) => {
+      state.addPartBulk([{ ...line, excluded: false }])
+    },
+    [state]
+  )
+  const guideStock = useMemo(() => ({ parts: inventoryParts }), [inventoryParts])
+
   const handleDescriptionChange = useCallback(
     (v: string) => {
       state.setDescription(v)
@@ -265,6 +294,10 @@ export function QuotePageClient({
         onAddService={state.addService}
         hasPresets={laborPresets.length > 0}
         onOpenPresets={() => setShowPresetPicker(true)}
+        vehicleId={state.vehicleId || null}
+        onAddGuideJob={handleAddGuideJob}
+        onAddGuidePart={handleAddGuidePart}
+        guideStock={guideStock}
         t={t}
       />
       <QuoteNotesEditor

+ 169 - 0
src/features/vehicles/Components/labor-guide/FluidPartButton.tsx

@@ -0,0 +1,169 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { Check, ChevronDown, Plus } from 'lucide-react'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import type { PricingSettings } from '@/features/inventory/Lib/partPricing'
+import {
+  type FluidPartLine,
+  fluidLitres,
+  fluidPartLine,
+  matchStock,
+  type StockPart,
+} from '@/features/integrations/Lib/fluid-stock'
+import type { FluidUnits } from '@/features/integrations/Lib/repair-prefs'
+import type { FluidSpec } from '@/features/integrations/Lib/types'
+import { cn } from '@/lib/utils'
+
+/** The workshop's stock and how this page prices a pick from it. */
+export interface LaborGuideStock {
+  parts: readonly StockPart[]
+  pricing?: PricingSettings
+}
+
+const LITRES_PER_QUART = 0.946353
+
+interface FluidPartButtonProps {
+  fluid: FluidSpec
+  units: FluidUnits
+  stock?: LaborGuideStock
+  added: boolean
+  onAdd: (line: FluidPartLine) => void
+}
+
+/**
+ * Puts a fluid on the job as a part. When the workshop stocks something with
+ * the same grade or spec, a short list offers it at the page's own price, in
+ * the amount the service takes; otherwise one click adds a plain line with
+ * the amount and no price, for the shop to price or swap for a stock item.
+ */
+export function FluidPartButton({ fluid, units, stock, added, onAdd }: FluidPartButtonProps) {
+  const t = useTranslations('service.laborGuide')
+  const tParts = useTranslations('service.parts')
+  const format = useFormatter()
+  const formatCurrency = useFormatCurrency()
+  const [open, setOpen] = useState(false)
+
+  const matches = useMemo(() => matchStock(fluid, stock?.parts ?? []), [fluid, stock])
+  const needed = fluidLitres(fluid)
+  const num = (n: number) => format.number(n, { maximumFractionDigits: 2 })
+  const amount = needed
+    ? units === 'us'
+      ? t('quarts', { value: num(needed.litres / LITRES_PER_QUART) })
+      : t('liters', { value: num(needed.litres) })
+    : null
+  const qty = (line: FluidPartLine) =>
+    line.unit ? `${num(line.quantity)} ${line.unit}` : num(line.quantity)
+
+  const add = (part?: StockPart) => {
+    onAdd(fluidPartLine(fluid, { units, part, pricing: stock?.pricing }))
+    setOpen(false)
+  }
+
+  if (added)
+    return (
+      <Button
+        type="button"
+        variant="secondary"
+        size="sm"
+        disabled
+        className="h-8 gap-1.5 text-xs text-primary disabled:opacity-100"
+      >
+        <Check className="size-3.5" />
+        {t('partAdded')}
+      </Button>
+    )
+
+  if (matches.length === 0)
+    return (
+      <Button
+        type="button"
+        variant="outline"
+        size="sm"
+        onClick={() => add()}
+        aria-label={t('addFluidAsPart', { system: fluid.system })}
+        className="h-8 gap-1.5 text-xs"
+      >
+        <Plus className="size-3.5" />
+        {t('addAsPart')}
+      </Button>
+    )
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          aria-label={t('addFluidAsPart', { system: fluid.system })}
+          className="h-8 gap-1.5 text-xs"
+        >
+          <Plus className="size-3.5" />
+          {t('addAsPart')}
+          <ChevronDown
+            className={cn('size-3.5 opacity-60 transition-transform', open && 'rotate-180')}
+          />
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent align="start" className="w-80 p-0">
+        <div className="border-b px-3 py-2.5">
+          <p className="text-sm font-medium">{t('addFluidAsPart', { system: fluid.system })}</p>
+          {needed && amount && (
+            <p className="mt-0.5 text-xs text-muted-foreground">
+              {needed.from === 'drainRefill'
+                ? t('takesDrainRefill', { amount })
+                : t('takesCapacity', { amount })}
+            </p>
+          )}
+        </div>
+        <p className="px-3 pt-2.5 pb-1 text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
+          {t('fromStock')}
+        </p>
+        <ul className="px-1.5 pb-1.5">
+          {matches.map(({ part }) => {
+            const line = fluidPartLine(fluid, { units, part, pricing: stock?.pricing })
+            return (
+              <li key={part.id}>
+                <button
+                  type="button"
+                  onClick={() => add(part)}
+                  className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left transition-colors hover:bg-accent"
+                >
+                  <span className="min-w-0">
+                    <span className="block truncate text-sm font-medium">{part.name}</span>
+                    <span className="block text-xs text-muted-foreground">
+                      {formatCurrency(line.unitPrice)}
+                      {part.unit ? ` / ${part.unit}` : ''}
+                      {' · '}
+                      {part.quantity > 0
+                        ? tParts('inStock', {
+                            quantity: part.unit
+                              ? `${num(part.quantity)} ${part.unit}`
+                              : num(part.quantity),
+                          })
+                        : tParts('outOfStock')}
+                    </span>
+                  </span>
+                  <span className="shrink-0 text-xs font-medium tabular-nums">{qty(line)}</span>
+                </button>
+              </li>
+            )
+          })}
+        </ul>
+        <div className="border-t p-1.5">
+          <button
+            type="button"
+            onClick={() => add()}
+            className="w-full rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+          >
+            {t('addWithoutStock')}
+          </button>
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 218 - 0
src/features/vehicles/Components/labor-guide/FluidSpecsPanel.tsx

@@ -0,0 +1,218 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { Check, Copy, Droplets, Loader2, Lock } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import type { RepairDataView } from '@/features/integrations/Actions/repairDataActions'
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
+import type { FluidUnits } from '@/features/integrations/Lib/repair-prefs'
+import type { FluidSpec } from '@/features/integrations/Lib/types'
+import { FluidPartButton, type LaborGuideStock } from './FluidPartButton'
+import { ConfidenceMark, GuideMessage, GuideNotices } from './guide-parts'
+
+export type FluidsView = Omit<RepairDataView<'fluids'>, 'items'> & { items: FluidSpec[] }
+
+interface FluidSpecsPanelProps {
+  view: FluidsView
+  settingsHref: string
+  unlocking: ReadonlySet<string>
+  onUnlock: (spec: FluidSpec) => void
+  /** Adds a fluid as a part line; without it the cards are for reading only. */
+  onAddPart?: (spec: FluidSpec, line: FluidPartLine) => void
+  /** Fluids already added from this dialog. */
+  addedParts?: ReadonlySet<string>
+  stock?: LaborGuideStock
+}
+
+/** The figure to lead with and the one beside it, in the units the workshop chose. */
+function inUnits(
+  metric: string | null,
+  us: string | null,
+  units: FluidUnits
+): { lead: string | null; aside: string | null } {
+  if (units === 'metric') return { lead: metric ?? us, aside: null }
+  if (units === 'us') return { lead: us ?? metric, aside: null }
+  return metric ? { lead: metric, aside: us } : { lead: us, aside: null }
+}
+
+/**
+ * What each system takes for one engine: a card per system with the
+ * capacity up front, the fluid and its spec, and how often it is changed.
+ */
+export function FluidSpecsPanel({
+  view,
+  settingsHref,
+  unlocking,
+  onUnlock,
+  onAddPart,
+  addedParts,
+  stock,
+}: FluidSpecsPanelProps) {
+  const t = useTranslations('service.laborGuide')
+  const format = useFormatter()
+  const [copied, setCopied] = useState<string | null>(null)
+
+  useEffect(() => {
+    if (!copied) return
+    const timer = setTimeout(() => setCopied(null), 1500)
+    return () => clearTimeout(timer)
+  }, [copied])
+
+  const num = (n: number) => format.number(n, { maximumFractionDigits: 2 })
+  const volume = (liters: number | null, quarts: number | null) =>
+    inUnits(
+      liters !== null ? t('liters', { value: num(liters) }) : null,
+      quarts !== null ? t('quarts', { value: num(quarts) }) : null,
+      view.prefs.units
+    )
+  const distance = (km: number | null, miles: number | null) =>
+    inUnits(
+      km !== null ? t('km', { value: num(km) }) : null,
+      miles !== null ? t('miles', { value: num(miles) }) : null,
+      view.prefs.units
+    )
+  const joined = (v: { lead: string | null; aside: string | null }) =>
+    [v.lead, v.aside].filter(Boolean).join(' / ')
+
+  const copy = (id: string, spec: string) => {
+    void navigator.clipboard?.writeText(spec).then(() => setCopied(id))
+  }
+
+  return (
+    <div className="flex h-full min-h-0 flex-col">
+      <GuideNotices
+        view={{ match: view.match, hidden: 0 }}
+        multipleEngines={view.variants.length > 1}
+        settingsHref={settingsHref}
+      />
+      <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
+        {view.items.length === 0 ? (
+          <GuideMessage icon={Droplets} title={t('noFluids')} />
+        ) : (
+          <div className="grid gap-3 p-4 sm:grid-cols-2 sm:p-5">
+            {view.items.map((f) => {
+              const busy = unlocking.has(f.id)
+              const capacity = volume(f.capacityLiters, f.capacityQuarts)
+              const drain = volume(f.drainRefillLiters, f.drainRefillQuarts)
+              const interval = distance(f.intervalKm, f.intervalMiles)
+              const showDrain = drain.lead && joined(drain) !== joined(capacity)
+              return (
+                <article key={f.id} className="flex flex-col rounded-xl border bg-card p-4">
+                  <header className="flex items-start justify-between gap-3">
+                    <div className="min-w-0">
+                      <h3 className="text-sm font-semibold">{f.system}</h3>
+                      {!f.locked && f.fluid && (
+                        <p className="mt-0.5 text-xs text-muted-foreground">{f.fluid}</p>
+                      )}
+                    </div>
+                    {!f.locked && <ConfidenceMark confidence={f.confidence} className="shrink-0" />}
+                  </header>
+
+                  {f.locked ? (
+                    <div className="mt-4 flex flex-1 items-center justify-between gap-3 rounded-lg border border-dashed px-3 py-3">
+                      <span className="text-xs text-muted-foreground">{t('lockedValue')}</span>
+                      <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        disabled={busy}
+                        onClick={() => onUnlock(f)}
+                        className="h-7 gap-1.5 text-xs"
+                      >
+                        {busy ? (
+                          <Loader2 className="size-3.5 animate-spin" />
+                        ) : (
+                          <Lock className="size-3.5" />
+                        )}
+                        {t('show')}
+                      </Button>
+                    </div>
+                  ) : (
+                    <>
+                      {capacity.lead && (
+                        <div className="mt-4">
+                          <p className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
+                            {t('capacity')}
+                          </p>
+                          <p className="mt-0.5 flex items-baseline gap-2">
+                            <span className="text-2xl font-semibold tracking-tight tabular-nums">
+                              {capacity.lead}
+                            </span>
+                            {capacity.aside && (
+                              <span className="text-sm tabular-nums text-muted-foreground">
+                                {capacity.aside}
+                              </span>
+                            )}
+                          </p>
+                        </div>
+                      )}
+
+                      {f.spec && (
+                        <button
+                          type="button"
+                          onClick={() => copy(f.id, f.spec ?? '')}
+                          aria-label={t('copySpec', { spec: f.spec })}
+                          className="group mt-3 inline-flex max-w-full items-center gap-2 self-start rounded-md bg-muted px-2 py-1 text-left font-mono text-xs transition-colors hover:bg-muted/70"
+                        >
+                          <span className="truncate">{f.spec}</span>
+                          {copied === f.id ? (
+                            <Check className="size-3 shrink-0 text-emerald-600 dark:text-emerald-400" />
+                          ) : (
+                            <Copy className="size-3 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100" />
+                          )}
+                        </button>
+                      )}
+
+                      {(showDrain || interval.lead) && (
+                        <dl className="mt-4 grid grid-cols-2 gap-3 border-t pt-3 text-xs">
+                          {showDrain && (
+                            <div>
+                              <dt className="text-muted-foreground">{t('drainRefill')}</dt>
+                              <dd className="mt-0.5 font-medium tabular-nums">{joined(drain)}</dd>
+                            </div>
+                          )}
+                          {interval.lead && (
+                            <div>
+                              <dt className="text-muted-foreground">{t('interval')}</dt>
+                              <dd className="mt-0.5 font-medium tabular-nums">
+                                {joined(interval)}
+                              </dd>
+                            </div>
+                          )}
+                        </dl>
+                      )}
+
+                      {f.notes && (
+                        <p className="mt-3 text-xs leading-relaxed text-muted-foreground">
+                          {f.notes}
+                        </p>
+                      )}
+
+                      {onAddPart && (
+                        <div className="mt-auto pt-4">
+                          <FluidPartButton
+                            fluid={f}
+                            units={view.prefs.units}
+                            stock={stock}
+                            added={addedParts?.has(f.id) ?? false}
+                            onAdd={(line) => onAddPart(f, line)}
+                          />
+                        </div>
+                      )}
+                    </>
+                  )}
+                </article>
+              )
+            })}
+          </div>
+        )}
+      </div>
+      {copied && (
+        <span className="sr-only" aria-live="polite">
+          {t('copied')}
+        </span>
+      )}
+    </div>
+  )
+}

+ 84 - 0
src/features/vehicles/Components/labor-guide/LaborGuideButton.tsx

@@ -0,0 +1,84 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { BookOpen } from 'lucide-react'
+import { IconActionButton } from '@/components/icon-action-button'
+import { Button } from '@/components/ui/button'
+import { isRepairDataAvailable } from '@/features/integrations/Actions/repairDataActions'
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
+import type { LaborGuideStock } from './FluidPartButton'
+import { type LaborGuideJob, LaborGuideDialog } from './LaborGuideDialog'
+
+interface LaborGuideButtonProps {
+  vehicleId: string
+  /** "2018 Ford F-150", shown under the dialog's title. */
+  vehicleLabel?: string
+  /** Adds a job from the guide as a labor line. */
+  onAdd: (job: LaborGuideJob) => void
+  /** Adds a fluid from the guide as a part line. */
+  onAddPart?: (line: FluidPartLine) => void
+  /** The workshop's stock, matched against each fluid's grade and spec. */
+  stock?: LaborGuideStock
+  /** A labelled button, for toolbars whose other buttons carry their names. */
+  labelled?: boolean
+}
+
+/**
+ * Opens the labor guide for the vehicle on a work order or a quote.
+ *
+ * Availability is checked here rather than passed in, like the VIN lookup
+ * button: the labor editors on both pages render this, and neither should
+ * have to know which integrations exist. Nothing shows until a repair data
+ * source is connected.
+ */
+export function LaborGuideButton({
+  vehicleId,
+  vehicleLabel,
+  onAdd,
+  onAddPart,
+  stock,
+  labelled = false,
+}: LaborGuideButtonProps) {
+  const t = useTranslations('service.laborGuide')
+  const [available, setAvailable] = useState<{ labor: boolean; fluids: boolean } | null>(null)
+  const [open, setOpen] = useState(false)
+
+  useEffect(() => {
+    let active = true
+    isRepairDataAvailable().then((res) => {
+      if (active) setAvailable(res.success && res.data ? res.data : null)
+    })
+    return () => {
+      active = false
+    }
+  }, [])
+
+  if (!available || (!available.labor && !available.fluids)) return null
+
+  return (
+    <>
+      {labelled ? (
+        <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
+          <BookOpen className="mr-1 h-3.5 w-3.5" />
+          {t('button')}
+        </Button>
+      ) : (
+        <IconActionButton label={t('button')} icon={BookOpen} onClick={() => setOpen(true)} />
+      )}
+      {/* Mounted while open only, so each opening asks again and a closed dialog holds nothing. */}
+      {open && (
+        <LaborGuideDialog
+          open={open}
+          onOpenChange={setOpen}
+          vehicleId={vehicleId}
+          vehicleLabel={vehicleLabel}
+          available={available}
+          onAdd={onAdd}
+          onAddPart={onAddPart}
+          stock={stock}
+        />
+      )}
+    </>
+  )
+}

+ 511 - 0
src/features/vehicles/Components/labor-guide/LaborGuideDialog.tsx

@@ -0,0 +1,511 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import Image from 'next/image'
+import Link from 'next/link'
+import {
+  AlertCircle,
+  BookOpen,
+  CarFront,
+  Droplets,
+  Info,
+  Loader2,
+  RotateCw,
+  Settings2,
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import {
+  getRepairData,
+  unlockRepairDataItem,
+} from '@/features/integrations/Actions/repairDataActions'
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
+import type { FluidSpec, LaborTime, RepairDataVariantRef } from '@/features/integrations/Lib/types'
+import { cn } from '@/lib/utils'
+import type { LaborGuideStock } from './FluidPartButton'
+import { type FluidsView, FluidSpecsPanel } from './FluidSpecsPanel'
+import { ConfidenceLegend, GuideMessage, useRegionName } from './guide-parts'
+import { type LaborView, LaborTimesPanel } from './LaborTimesPanel'
+
+type Kind = 'labor' | 'fluids'
+
+type Load<V> =
+  | { status: 'loading' }
+  | { status: 'none' }
+  | { status: 'failed'; message: string }
+  | { status: 'found'; view: V }
+
+export interface LaborGuideJob {
+  description: string
+  hours: number
+}
+
+interface LaborGuideDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  vehicleId: string
+  /** "2018 Ford F-150", under the title. */
+  vehicleLabel?: string
+  available: Record<Kind, boolean>
+  onAdd: (job: LaborGuideJob) => void
+  /** Adds a fluid as a part line. Without it the fluid cards are for reading only. */
+  onAddPart?: (line: FluidPartLine) => void
+  /** The workshop's stock, offered when it carries a fluid's grade or spec. */
+  stock?: LaborGuideStock
+}
+
+/** The list with one item replaced by its unlocked value. */
+function withItem<V extends { items: { id: string }[] }, T extends { id: string }>(
+  state: Load<V>,
+  value: T
+): Load<V> {
+  if (state.status !== 'found') return state
+  return {
+    status: 'found',
+    view: { ...state.view, items: state.view.items.map((i) => (i.id === value.id ? value : i)) },
+  }
+}
+
+/**
+ * The list without a value the workshop's minimum confidence leaves out,
+ * counted with the others it hides.
+ */
+function withoutItem<V extends { items: { id: string }[]; hidden: number }>(
+  state: Load<V>,
+  id: string
+): Load<V> {
+  if (state.status !== 'found') return state
+  const items = state.view.items.filter((i) => i.id !== id)
+  if (items.length === state.view.items.length) return state
+  return { status: 'found', view: { ...state.view, items, hidden: state.view.hidden + 1 } }
+}
+
+/** How long a lookup runs before the dialog says it is still at it. */
+const SLOW_AFTER_MS = 6000
+
+/**
+ * The shape of the list while it loads, so nothing jumps when it arrives.
+ * A vehicle the source has little data for can take it half a minute to
+ * answer, so after a few seconds a line says the lookup is still going.
+ */
+function LoadingState({
+  kind,
+  label,
+  slowLabel,
+}: {
+  kind: Kind
+  label: string
+  slowLabel: string
+}) {
+  const rows = [68, 52, 74, 46, 60, 70, 40, 56, 64]
+  const [slow, setSlow] = useState(false)
+  useEffect(() => {
+    const timer = setTimeout(() => setSlow(true), SLOW_AFTER_MS)
+    return () => clearTimeout(timer)
+  }, [])
+  return (
+    <div className="flex h-full min-h-0 flex-col" aria-busy>
+      <span className="sr-only" role="status">
+        {slow ? slowLabel : label}
+      </span>
+      {slow && (
+        <div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2.5 text-xs text-muted-foreground sm:px-6">
+          <Loader2 className="size-3.5 shrink-0 animate-spin" />
+          {slowLabel}
+        </div>
+      )}
+      <div className="grid min-h-0 flex-1 md:grid-cols-[13.5rem_minmax(0,1fr)]">
+        {kind === 'labor' && (
+          <div className="hidden space-y-2 border-r bg-muted/20 p-3 md:block">
+            {[80, 64, 72, 56, 68, 60, 76, 52].map((w, i) => (
+              <Skeleton key={i} className="h-6" style={{ width: `${w}%` }} />
+            ))}
+          </div>
+        )}
+        <div className={cn('min-h-0', kind === 'fluids' && 'md:col-span-2')}>
+          {kind === 'labor' ? (
+            <>
+              <div className="border-b px-4 py-3 sm:px-5">
+                <Skeleton className="h-10 w-full" />
+              </div>
+              <div className="divide-y">
+                {rows.map((w, i) => (
+                  <div key={i} className="flex items-center gap-4 px-4 py-3 sm:px-5">
+                    <div className="flex-1 space-y-1.5">
+                      <Skeleton className="h-3.5" style={{ width: `${w}%` }} />
+                      <Skeleton className="h-3 w-20" />
+                    </div>
+                    <Skeleton className="h-4 w-12" />
+                    <Skeleton className="size-8 rounded-full" />
+                  </div>
+                ))}
+              </div>
+            </>
+          ) : (
+            <div className="grid gap-3 p-4 sm:grid-cols-2 sm:p-5">
+              {[0, 1, 2, 3].map((i) => (
+                <div key={i} className="space-y-3 rounded-xl border p-4">
+                  <Skeleton className="h-4 w-32" />
+                  <Skeleton className="h-7 w-24" />
+                  <Skeleton className="h-5 w-40" />
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+/**
+ * The labor guide for one vehicle: book times to add as labor lines, and
+ * fluid capacities to read, for the engine the guide picks or the person
+ * chooses. A key that lists everything but unlocks values one at a time gets
+ * a button per row that asks for that one value.
+ */
+export function LaborGuideDialog({
+  open,
+  onOpenChange,
+  vehicleId,
+  vehicleLabel,
+  available,
+  onAdd,
+  onAddPart,
+  stock,
+}: LaborGuideDialogProps) {
+  const t = useTranslations('service.laborGuide')
+  const both = available.labor && available.fluids
+  const [tab, setTab] = useState<Kind>(available.labor ? 'labor' : 'fluids')
+  const [labor, setLabor] = useState<Load<LaborView>>({ status: 'loading' })
+  const [fluids, setFluids] = useState<Load<FluidsView>>({ status: 'loading' })
+  const [unlocking, setUnlocking] = useState<Set<string>>(new Set())
+  const [unlockError, setUnlockError] = useState<string | null>(null)
+  const [added, setAdded] = useState<Set<string>>(new Set())
+  const [addedParts, setAddedParts] = useState<Set<string>>(new Set())
+
+  const load = useCallback(
+    async (kind: Kind, variant?: RepairDataVariantRef) => {
+      const set = (kind === 'labor' ? setLabor : setFluids) as (next: Load<unknown>) => void
+      set({ status: 'loading' })
+      const res = await getRepairData({ vehicleId, kind, variant })
+      if (!res.success || !res.data) {
+        set({ status: 'failed', message: res.error ?? t('failed') })
+        return
+      }
+      const outcome = res.data
+      if (outcome.status === 'found') set({ status: 'found', view: outcome.value })
+      else if (outcome.status === 'none') set({ status: 'none' })
+      else set({ status: 'failed', message: outcome.message })
+    },
+    [vehicleId, t]
+  )
+
+  // Each tab asks once, the first time it is shown. Remembered in a ref, not
+  // read from state, so a second run of the effect (React's development
+  // check) cannot spend another call.
+  const asked = useRef(new Set<Kind>())
+  useEffect(() => {
+    if (!open || asked.current.has(tab)) return
+    asked.current.add(tab)
+    void load(tab)
+  }, [open, tab, load])
+
+  const unlock = async (kind: Kind, variant: RepairDataVariantRef, item: string) => {
+    setUnlocking((prev) => new Set(prev).add(`${kind}:${item}`))
+    setUnlockError(null)
+    const res = await unlockRepairDataItem({ vehicleId, kind, variant, item })
+    setUnlocking((prev) => {
+      const next = new Set(prev)
+      next.delete(`${kind}:${item}`)
+      return next
+    })
+    const outcome = res.success ? res.data : null
+    // Below the workshop's minimum confidence once shown: the row goes, and
+    // the line above the list counts it with the others the setting hides.
+    if (outcome?.status === 'hidden') {
+      if (kind === 'labor') setLabor((prev) => withoutItem(prev, item))
+      return
+    }
+    if (!outcome || outcome.status !== 'found') {
+      setUnlockError(
+        outcome?.status === 'failed' ? outcome.message : (res.error ?? t('unlockFailed'))
+      )
+      return
+    }
+    // From the latest state, so two values asked for at once both land.
+    const value = outcome.value
+    if (kind === 'labor') setLabor((prev) => withItem(prev, value as LaborTime))
+    else setFluids((prev) => withItem(prev, value as FluidSpec))
+  }
+
+  const current = tab === 'labor' ? labor : fluids
+  const view = current.status === 'found' ? current.view : null
+  const anyView =
+    (labor.status === 'found' && labor.view) || (fluids.status === 'found' && fluids.view) || null
+  const region = useRegionName(anyView?.region ?? null)
+  const settingsHref = `/settings/integrations/${anyView?.connector ?? ''}`
+  const anyLocked =
+    (labor.status === 'found' && labor.view.items.some((i) => i.locked)) ||
+    (fluids.status === 'found' && fluids.view.items.some((i) => i.locked))
+
+  const unlockingOf = (kind: Kind) =>
+    new Set(
+      [...unlocking].filter((k) => k.startsWith(`${kind}:`)).map((k) => k.slice(kind.length + 1))
+    )
+
+  const body = (kind: Kind) => {
+    const state = kind === 'labor' ? labor : fluids
+    if (state.status === 'loading')
+      return <LoadingState kind={kind} label={t('loading')} slowLabel={t('stillLooking')} />
+    if (state.status === 'none')
+      return kind === 'labor' ? (
+        <GuideMessage icon={CarFront} title={t('notFoundTitle')}>
+          {t('notFound')}
+        </GuideMessage>
+      ) : (
+        <GuideMessage icon={Droplets} title={t('fluidsNotFoundTitle')}>
+          {t('fluidsNotFound')}
+        </GuideMessage>
+      )
+    if (state.status === 'failed')
+      return (
+        <GuideMessage
+          icon={AlertCircle}
+          tone="destructive"
+          title={t('failedTitle')}
+          action={
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={() => {
+                if (kind === 'labor') setAdded(new Set())
+                void load(kind)
+              }}
+            >
+              <RotateCw className="size-3.5" />
+              {t('retry')}
+            </Button>
+          }
+        >
+          {state.message}
+        </GuideMessage>
+      )
+    if (kind === 'labor' && labor.status === 'found')
+      return (
+        <LaborTimesPanel
+          view={labor.view}
+          settingsHref={settingsHref}
+          added={added}
+          unlocking={unlockingOf('labor')}
+          onAdd={(job, hours) => {
+            onAdd({ description: job.name, hours })
+            setAdded((prev) => new Set(prev).add(job.id))
+          }}
+          onUnlock={(job) =>
+            void unlock('labor', labor.view.variants[labor.view.selected].ref, job.id)
+          }
+        />
+      )
+    if (kind === 'fluids' && fluids.status === 'found')
+      return (
+        <FluidSpecsPanel
+          view={fluids.view}
+          settingsHref={settingsHref}
+          unlocking={unlockingOf('fluids')}
+          onUnlock={(spec) =>
+            void unlock('fluids', fluids.view.variants[fluids.view.selected].ref, spec.id)
+          }
+          stock={stock}
+          addedParts={addedParts}
+          onAddPart={
+            onAddPart
+              ? (spec, line) => {
+                  onAddPart(line)
+                  setAddedParts((prev) => new Set(prev).add(spec.id))
+                }
+              : undefined
+          }
+        />
+      )
+    return null
+  }
+
+  const count = (state: Load<{ items: unknown[] }>) =>
+    state.status === 'found' ? (
+      <span className="ml-1.5 rounded-full bg-muted px-1.5 py-px text-[10px] font-medium tabular-nums text-muted-foreground group-data-[state=active]:bg-primary/10 group-data-[state=active]:text-primary">
+        {state.view.items.length}
+      </span>
+    ) : null
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="flex h-[min(46rem,92dvh)] max-h-[92dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
+        <Tabs
+          value={tab}
+          onValueChange={(v) => setTab(v as Kind)}
+          className="flex min-h-0 flex-1 flex-col gap-0"
+        >
+          <div className="border-b px-4 pt-5 pb-4 sm:px-6">
+            <div className="flex items-center gap-3 pr-8">
+              {anyView?.logo ? (
+                <Image
+                  src={anyView.logo}
+                  alt=""
+                  width={40}
+                  height={40}
+                  className="size-10 shrink-0 rounded-xl"
+                  unoptimized
+                />
+              ) : (
+                <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border bg-muted/40">
+                  <BookOpen className="size-5 text-muted-foreground" />
+                </div>
+              )}
+              <div className="min-w-0">
+                <DialogTitle className="text-base leading-tight">{t('title')}</DialogTitle>
+                <DialogDescription className="mt-0.5 truncate">
+                  {vehicleLabel || t('description')}
+                </DialogDescription>
+              </div>
+            </div>
+
+            <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+              {both ? (
+                <TabsList>
+                  <TabsTrigger value="labor" className="group px-3">
+                    {t('tabLabor')}
+                    {count(labor)}
+                  </TabsTrigger>
+                  <TabsTrigger value="fluids" className="group px-3">
+                    {t('tabFluids')}
+                    {count(fluids)}
+                  </TabsTrigger>
+                </TabsList>
+              ) : (
+                <span />
+              )}
+              {view && view.variants.length > 1 && (
+                <Select
+                  value={String(view.selected)}
+                  onValueChange={(v) => {
+                    if (tab === 'labor') setAdded(new Set())
+                    else setAddedParts(new Set())
+                    void load(tab, view.variants[Number(v)].ref)
+                  }}
+                >
+                  <SelectTrigger
+                    aria-label={t('engine')}
+                    className="h-9 w-full gap-2 sm:w-auto sm:min-w-64 sm:max-w-80"
+                  >
+                    <span className="shrink-0 text-xs text-muted-foreground">{t('engine')}</span>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent align="end">
+                    {view.variants.map((v, i) => (
+                      <SelectItem
+                        key={`${v.ref.engineId}-${v.ref.drivetrain}-${i}`}
+                        value={String(i)}
+                      >
+                        {v.label}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              )}
+            </div>
+          </div>
+
+          {/* Both stay mounted, so a search or category survives a look at the other tab. */}
+          <TabsContent value="labor" forceMount className="min-h-0 flex-1">
+            {available.labor && (tab === 'labor' || labor.status !== 'loading') && body('labor')}
+          </TabsContent>
+          <TabsContent value="fluids" forceMount className="min-h-0 flex-1">
+            {available.fluids &&
+              (tab === 'fluids' || fluids.status !== 'loading') &&
+              body('fluids')}
+          </TabsContent>
+        </Tabs>
+
+        {unlockError && (
+          <p
+            role="alert"
+            className="border-t bg-destructive/5 px-4 py-2 text-xs text-destructive sm:px-6"
+          >
+            {unlockError}
+          </p>
+        )}
+
+        <footer className="flex flex-col gap-3 border-t bg-muted/30 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6">
+          <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
+            {anyView && (
+              <span className="truncate">
+                {t('attribution', { source: anyView.source })}
+                {region && <> · {t('market', { region })}</>}
+              </span>
+            )}
+            <Popover>
+              <PopoverTrigger asChild>
+                <button
+                  type="button"
+                  className="inline-flex items-center gap-1 font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline"
+                >
+                  <Info className="size-3.5" />
+                  {t('about')}
+                </button>
+              </PopoverTrigger>
+              <PopoverContent align="start" className="w-80 space-y-3">
+                <p className="text-sm font-medium">{t('aboutTitle')}</p>
+                <ConfidenceLegend />
+                <p className="border-t pt-3 text-xs text-muted-foreground">{t('estimateNote')}</p>
+                {anyLocked && <p className="text-xs text-muted-foreground">{t('lockedNote')}</p>}
+              </PopoverContent>
+            </Popover>
+            {anyView && (
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <Link
+                    href={settingsHref}
+                    aria-label={t('settings')}
+                    className="inline-flex size-6 items-center justify-center rounded-md hover:bg-muted hover:text-foreground"
+                  >
+                    <Settings2 className="size-3.5" />
+                  </Link>
+                </TooltipTrigger>
+                <TooltipContent>{t('settings')}</TooltipContent>
+              </Tooltip>
+            )}
+          </div>
+          <div className="flex items-center justify-end gap-3">
+            {(added.size > 0 || addedParts.size > 0) && (
+              <span className="text-xs text-muted-foreground" aria-live="polite">
+                {[
+                  added.size > 0 ? t('addedCount', { count: added.size }) : null,
+                  addedParts.size > 0 ? t('partsAddedCount', { count: addedParts.size }) : null,
+                ]
+                  .filter(Boolean)
+                  .join(' · ')}
+              </span>
+            )}
+            <Button type="button" size="sm" onClick={() => onOpenChange(false)}>
+              {t('done')}
+            </Button>
+          </div>
+        </footer>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 326 - 0
src/features/vehicles/Components/labor-guide/LaborTimesPanel.tsx

@@ -0,0 +1,326 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { Check, Loader2, Lock, Plus, Search, SearchX, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { RepairDataView } from '@/features/integrations/Actions/repairDataActions'
+import { adjustsHours, hoursToAdd } from '@/features/integrations/Lib/repair-prefs'
+import type { LaborTime } from '@/features/integrations/Lib/types'
+import { cn } from '@/lib/utils'
+import { categoryLabel, ConfidenceMark, GuideMessage, GuideNotices, Highlight } from './guide-parts'
+
+export type LaborView = Omit<RepairDataView<'labor'>, 'items'> & { items: LaborTime[] }
+
+interface LaborTimesPanelProps {
+  view: LaborView
+  settingsHref: string
+  added: ReadonlySet<string>
+  unlocking: ReadonlySet<string>
+  onAdd: (job: LaborTime, hours: number) => void
+  onUnlock: (job: LaborTime) => void
+}
+
+/**
+ * Every job the guide lists for one engine: a category rail beside the list
+ * on a wide screen and a row of chips above it on a narrow one, a search
+ * that marks what it matched, and per job the hours it adds, how sure the
+ * source is of them, and a button that adds it as a labor line.
+ */
+export function LaborTimesPanel({
+  view,
+  settingsHref,
+  added,
+  unlocking,
+  onAdd,
+  onUnlock,
+}: LaborTimesPanelProps) {
+  const t = useTranslations('service.laborGuide')
+  const format = useFormatter()
+  const [query, setQuery] = useState('')
+  const [category, setCategory] = useState<string | null>(null)
+  const searchRef = useRef<HTMLInputElement>(null)
+  const listRef = useRef<HTMLDivElement>(null)
+
+  // Straight into the search on a desktop; on a phone the keyboard would
+  // cover the list before anyone asked for it.
+  useEffect(() => {
+    if (window.matchMedia?.('(min-width: 768px)').matches) searchRef.current?.focus()
+  }, [])
+
+  const counts = useMemo(() => {
+    const out = new Map<string, number>()
+    for (const job of view.items) out.set(job.category, (out.get(job.category) ?? 0) + 1)
+    return out
+  }, [view.items])
+  const categories = useMemo(
+    () => [...counts.keys()].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b))),
+    [counts]
+  )
+  // A category the newly picked engine does not have falls back to all of them.
+  const active = category && counts.has(category) ? category : null
+
+  const q = query.trim().toLowerCase()
+  const groups = useMemo(() => {
+    const matches = view.items
+      .filter((j) => !active || j.category === active)
+      .filter(
+        (j) =>
+          !q ||
+          j.name.toLowerCase().includes(q) ||
+          categoryLabel(j.category).toLowerCase().includes(q)
+      )
+      .sort((a, b) => a.name.localeCompare(b.name))
+    const byCategory = new Map<string, LaborTime[]>()
+    for (const job of matches) {
+      const list = byCategory.get(job.category) ?? []
+      list.push(job)
+      byCategory.set(job.category, list)
+    }
+    return [...byCategory.entries()]
+      .sort(([a], [b]) => categoryLabel(a).localeCompare(categoryLabel(b)))
+      .map(([key, jobs]) => ({ key, jobs }))
+  }, [view.items, active, q])
+  const shown = groups.reduce((n, g) => n + g.jobs.length, 0)
+
+  const pick = (next: string | null) => {
+    setCategory(next)
+    if (listRef.current) listRef.current.scrollTop = 0
+  }
+
+  const hours = (n: number) => t('hours', { hours: format.number(n, { maximumFractionDigits: 2 }) })
+  const showBook = adjustsHours(view.prefs)
+
+  const railItem = (key: string | null, label: string, count: number) => (
+    <button
+      key={key ?? 'all'}
+      type="button"
+      onClick={() => pick(key)}
+      aria-current={active === key ? 'true' : undefined}
+      className={cn(
+        'flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors',
+        active === key
+          ? 'bg-accent font-medium text-accent-foreground'
+          : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
+      )}
+    >
+      <span className="truncate">{label}</span>
+      <span className="shrink-0 text-xs tabular-nums opacity-70">{count}</span>
+    </button>
+  )
+
+  const chip = (key: string | null, label: string) => (
+    <button
+      key={key ?? 'all'}
+      type="button"
+      onClick={() => pick(key)}
+      aria-pressed={active === key}
+      className={cn(
+        'shrink-0 rounded-full border px-3 py-1 text-xs transition-colors',
+        active === key
+          ? 'border-primary bg-primary text-primary-foreground'
+          : 'bg-background text-muted-foreground hover:text-foreground'
+      )}
+    >
+      {label}
+    </button>
+  )
+
+  return (
+    <div className="grid h-full min-h-0 md:grid-cols-[13.5rem_minmax(0,1fr)]">
+      <nav
+        aria-label={t('categories')}
+        className="hidden min-h-0 overflow-y-auto overscroll-contain border-r bg-muted/20 p-3 md:block"
+      >
+        <div className="space-y-0.5">
+          {railItem(null, t('allJobs'), view.items.length)}
+          <div className="my-2 border-t" />
+          {categories.map((c) => railItem(c, categoryLabel(c), counts.get(c) ?? 0))}
+        </div>
+      </nav>
+
+      <div className="flex min-h-0 flex-col">
+        <div className="space-y-3 border-b px-4 py-3 sm:px-5">
+          <div className="relative">
+            <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
+            <Input
+              ref={searchRef}
+              type="search"
+              value={query}
+              onChange={(e) => setQuery(e.target.value)}
+              placeholder={t('search')}
+              aria-label={t('search')}
+              className="h-10 pr-9 pl-9 [&::-webkit-search-cancel-button]:hidden"
+            />
+            {query && (
+              <button
+                type="button"
+                onClick={() => {
+                  setQuery('')
+                  searchRef.current?.focus()
+                }}
+                aria-label={t('clearSearch')}
+                className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
+              >
+                <X className="size-3.5" />
+              </button>
+            )}
+          </div>
+          <div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-0.5 [scrollbar-width:none] sm:-mx-5 sm:px-5 md:hidden">
+            {chip(null, t('allJobs'))}
+            {categories.map((c) => chip(c, categoryLabel(c)))}
+          </div>
+        </div>
+
+        <GuideNotices
+          view={view}
+          multipleEngines={view.variants.length > 1}
+          settingsHref={settingsHref}
+        />
+
+        <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
+          {shown === 0 ? (
+            <GuideMessage
+              icon={SearchX}
+              title={q ? t('noMatch', { query: query.trim() }) : t('noJobs')}
+              action={
+                q || active ? (
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    onClick={() => {
+                      setQuery('')
+                      pick(null)
+                    }}
+                  >
+                    {t('showAll')}
+                  </Button>
+                ) : undefined
+              }
+            />
+          ) : (
+            <>
+              <p
+                className="px-4 pt-3 pb-1 text-xs text-muted-foreground sm:px-5"
+                aria-live="polite"
+              >
+                {t('jobCount', { count: shown })}
+              </p>
+              {groups.map((group) => (
+                <section key={group.key} aria-label={categoryLabel(group.key)}>
+                  {!active && (
+                    <h3 className="sticky top-0 z-10 border-b bg-background/95 px-4 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground uppercase backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-5">
+                      {categoryLabel(group.key)}
+                    </h3>
+                  )}
+                  <ul className="divide-y">
+                    {group.jobs.map((job) => {
+                      const busy = unlocking.has(job.id)
+                      const isAdded = added.has(job.id)
+                      const adds = hoursToAdd(job, view.prefs)
+                      const range =
+                        job.low !== null && job.high !== null && job.low !== job.high
+                          ? t('range', {
+                              low: format.number(job.low, { maximumFractionDigits: 2 }),
+                              high: format.number(job.high, { maximumFractionDigits: 2 }),
+                            })
+                          : null
+                      return (
+                        <li
+                          key={job.id}
+                          className={cn(
+                            'flex items-center gap-3 px-4 py-2.5 transition-colors sm:gap-4 sm:px-5',
+                            isAdded ? 'bg-primary/[0.04]' : 'hover:bg-muted/40'
+                          )}
+                        >
+                          <div className="min-w-0 flex-1">
+                            <p className="truncate text-sm font-medium">
+                              <Highlight text={job.name} query={query} />
+                            </p>
+                            {!job.locked && (
+                              <div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
+                                <ConfidenceMark confidence={job.confidence} />
+                                {range && (
+                                  <span className="text-xs tabular-nums text-muted-foreground">
+                                    {range}
+                                  </span>
+                                )}
+                              </div>
+                            )}
+                          </div>
+
+                          {job.locked || adds === null ? (
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="sm"
+                              disabled={busy}
+                              onClick={() => onUnlock(job)}
+                              className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
+                            >
+                              {busy ? (
+                                <Loader2 className="size-3.5 animate-spin" />
+                              ) : (
+                                <Lock className="size-3.5" />
+                              )}
+                              {t('showHours')}
+                            </Button>
+                          ) : (
+                            <>
+                              <div className="w-20 shrink-0 text-right">
+                                <p className="text-sm font-semibold tabular-nums">{hours(adds)}</p>
+                                {showBook && job.hours !== null && job.hours !== adds && (
+                                  <p className="text-[11px] tabular-nums text-muted-foreground">
+                                    {t('bookTime', {
+                                      hours: format.number(job.hours, {
+                                        maximumFractionDigits: 2,
+                                      }),
+                                    })}
+                                  </p>
+                                )}
+                              </div>
+                              <Tooltip>
+                                <TooltipTrigger asChild>
+                                  <Button
+                                    type="button"
+                                    size="icon"
+                                    variant={isAdded ? 'secondary' : 'outline'}
+                                    disabled={isAdded}
+                                    onClick={() => onAdd(job, adds)}
+                                    aria-label={
+                                      isAdded
+                                        ? t('addedJob', { job: job.name })
+                                        : t('addJob', { job: job.name })
+                                    }
+                                    className={cn(
+                                      'size-8 shrink-0 rounded-full',
+                                      isAdded && 'text-primary disabled:opacity-100'
+                                    )}
+                                  >
+                                    {isAdded ? (
+                                      <Check className="size-4" />
+                                    ) : (
+                                      <Plus className="size-4" />
+                                    )}
+                                  </Button>
+                                </TooltipTrigger>
+                                <TooltipContent>{isAdded ? t('added') : t('add')}</TooltipContent>
+                              </Tooltip>
+                            </>
+                          )}
+                        </li>
+                      )
+                    })}
+                  </ul>
+                </section>
+              ))}
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 7 - 0
src/features/vehicles/Components/labor-guide/guide-lines.ts

@@ -0,0 +1,7 @@
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
+import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
+
+/** A fluid from the labor guide as a work order part line, the shape a pick from stock has. */
+export function servicePartFromFluid(line: FluidPartLine): ServicePartInput {
+  return { ...line, inventoryPartId: line.inventoryPartId ?? undefined }
+}

+ 197 - 0
src/features/vehicles/Components/labor-guide/guide-parts.tsx

@@ -0,0 +1,197 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useMemo } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import Link from 'next/link'
+import type { LucideIcon } from 'lucide-react'
+import { AlertTriangle, Info } from 'lucide-react'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { RepairDataView } from '@/features/integrations/Actions/repairDataActions'
+import type { RepairDataConfidence } from '@/features/integrations/Lib/types'
+import { cn } from '@/lib/utils'
+
+/** "fuel-system" as "Fuel system", "hvac" as "HVAC": the source's own words, in English. */
+export function categoryLabel(category: string): string {
+  if (category.length <= 4 && !category.includes('-')) return category.toUpperCase()
+  const words = category.replace(/[-_]+/g, ' ')
+  return words.charAt(0).toUpperCase() + words.slice(1)
+}
+
+export const CONFIDENCE_ORDER: RepairDataConfidence[] = ['oem', 'high', 'medium', 'estimated']
+
+const DOT: Record<RepairDataConfidence, string> = {
+  oem: 'bg-emerald-500 ring-2 ring-emerald-500/25',
+  high: 'bg-emerald-500',
+  medium: 'bg-blue-500',
+  estimated: 'bg-amber-500',
+}
+
+/** A coloured dot and the level's name, with what it means on hover. */
+export function ConfidenceMark({
+  confidence,
+  className,
+}: {
+  confidence: RepairDataConfidence | null
+  className?: string
+}) {
+  const t = useTranslations('service.laborGuide.confidence')
+  if (!confidence) return null
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>
+        <span
+          className={cn(
+            'inline-flex cursor-default items-center gap-1.5 text-xs text-muted-foreground',
+            className
+          )}
+        >
+          <span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', DOT[confidence])} />
+          {t(confidence)}
+        </span>
+      </TooltipTrigger>
+      <TooltipContent className="max-w-60">{t(`${confidence}Help`)}</TooltipContent>
+    </Tooltip>
+  )
+}
+
+/** The legend for the footer's "how sure" popover. */
+export function ConfidenceLegend() {
+  const t = useTranslations('service.laborGuide.confidence')
+  return (
+    <ul className="space-y-2.5">
+      {CONFIDENCE_ORDER.map((c) => (
+        <li key={c} className="flex gap-2.5">
+          <span aria-hidden className={cn('mt-1.5 size-1.5 shrink-0 rounded-full', DOT[c])} />
+          <div className="text-xs">
+            <p className="font-medium text-foreground">{t(c)}</p>
+            <p className="text-muted-foreground">{t(`${c}Help`)}</p>
+          </div>
+        </li>
+      ))}
+    </ul>
+  )
+}
+
+/** The part of a name that matches the search, marked. */
+export function Highlight({ text, query }: { text: string; query: string }) {
+  const q = query.trim()
+  if (!q) return <>{text}</>
+  const at = text.toLowerCase().indexOf(q.toLowerCase())
+  if (at < 0) return <>{text}</>
+  return (
+    <>
+      {text.slice(0, at)}
+      <mark className="rounded-sm bg-primary/15 px-0.5 text-foreground">
+        {text.slice(at, at + q.length)}
+      </mark>
+      {text.slice(at + q.length)}
+    </>
+  )
+}
+
+/** "United States" for "US", in the reader's language. */
+export function useRegionName(region: string | null): string | null {
+  const locale = useLocale()
+  return useMemo(() => {
+    if (!region) return null
+    try {
+      return new Intl.DisplayNames([locale], { type: 'region' }).of(region) ?? region
+    } catch {
+      return region
+    }
+  }, [locale, region])
+}
+
+/** A centred state for the whole panel: nothing found, a failure, an empty list. */
+export function GuideMessage({
+  icon: Icon,
+  tone = 'muted',
+  title,
+  children,
+  action,
+}: {
+  icon: LucideIcon
+  tone?: 'muted' | 'destructive'
+  title: string
+  children?: ReactNode
+  action?: ReactNode
+}) {
+  return (
+    <div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
+      <div
+        className={cn(
+          'mb-4 flex size-12 items-center justify-center rounded-full',
+          tone === 'destructive'
+            ? 'bg-destructive/10 text-destructive'
+            : 'bg-muted text-muted-foreground'
+        )}
+      >
+        <Icon className="size-5" />
+      </div>
+      <p className="text-sm font-medium">{title}</p>
+      {children && (
+        <div className="mt-1.5 max-w-sm text-sm text-pretty text-muted-foreground">{children}</div>
+      )}
+      {action && <div className="mt-5">{action}</div>}
+    </div>
+  )
+}
+
+/**
+ * What the person should know before trusting the list: the engine may not
+ * be this vehicle's, or the workshop's settings hide some values.
+ */
+export function GuideNotices({
+  view,
+  multipleEngines,
+  settingsHref,
+}: {
+  view: Pick<RepairDataView<'labor'>, 'match' | 'hidden'>
+  multipleEngines: boolean
+  settingsHref: string
+}) {
+  const t = useTranslations('service.laborGuide')
+  const engine =
+    view.match === 'none'
+      ? { tone: 'warn' as const, text: t('matchNone') }
+      : multipleEngines && view.match === 'fuel'
+        ? { tone: 'info' as const, text: t('matchFuel') }
+        : multipleEngines && view.match === 'unknown'
+          ? { tone: 'info' as const, text: t('matchUnknown') }
+          : null
+  if (!engine && view.hidden === 0) return null
+  return (
+    <div className="space-y-2 px-4 pt-3 sm:px-5">
+      {engine && (
+        <div
+          role={engine.tone === 'warn' ? 'alert' : undefined}
+          className={cn(
+            'flex gap-2.5 rounded-lg border px-3 py-2.5 text-xs leading-relaxed',
+            engine.tone === 'warn'
+              ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'
+              : 'bg-muted/40 text-muted-foreground'
+          )}
+        >
+          {engine.tone === 'warn' ? (
+            <AlertTriangle className="mt-px size-3.5 shrink-0" />
+          ) : (
+            <Info className="mt-px size-3.5 shrink-0" />
+          )}
+          <p>{engine.text}</p>
+        </div>
+      )}
+      {view.hidden > 0 && (
+        <p className="text-xs text-muted-foreground">
+          {t('hiddenByConfidence', { count: view.hidden })}{' '}
+          <Link
+            href={settingsHref}
+            className="font-medium text-foreground underline-offset-4 hover:underline"
+          >
+            {t('changeSettings')}
+          </Link>
+        </p>
+      )}
+    </div>
+  )
+}

+ 44 - 0
src/features/vehicles/Components/service-edit/LaborEditor.tsx

@@ -27,6 +27,10 @@ import { useFormatCurrency, useCurrencySettings } from '@/components/currency-se
 import { useTranslations } from 'next-intl'
 import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
 import { makeEmptyLabor, makeEmptyService } from './form-types'
+import type { FluidPartLine } from '@/features/integrations/Lib/fluid-stock'
+import type { LaborGuideStock } from '../labor-guide/FluidPartButton'
+import { LaborGuideButton } from '../labor-guide/LaborGuideButton'
+import type { LaborGuideJob } from '../labor-guide/LaborGuideDialog'
 import {
   DndContext,
   closestCenter,
@@ -57,6 +61,14 @@ interface LaborEditorProps {
   onAddFinding?: () => void
   openObservationsCount?: number
   onShowExistingObservations?: () => void
+  /** The job's vehicle, for the labor guide. Counter sales have none and get no guide. */
+  vehicleId?: string | null
+  /** "2018 Ford F-150", shown in the labor guide. */
+  vehicleLabel?: string
+  /** Adds a fluid from the labor guide to the job's parts. */
+  onAddGuidePart?: (line: FluidPartLine) => void
+  /** The workshop's stock, for the labor guide to match fluids against. */
+  guideStock?: LaborGuideStock
 }
 
 function SortableLaborRow({
@@ -213,6 +225,10 @@ export function LaborEditor({
   onAddFinding,
   openObservationsCount = 0,
   onShowExistingObservations,
+  vehicleId,
+  vehicleLabel,
+  onAddGuidePart,
+  guideStock,
 }: LaborEditorProps) {
   const t = useTranslations('service.labor')
   const modern = useModernWorkOrder()
@@ -268,6 +284,25 @@ export function LaborEditor({
     setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])
   }, [setLaborItems, defaultLaborRate])
 
+  // A job from the labor guide: its name and book hours at the workshop's
+  // rate, on top like every other line added from the toolbar.
+  const addGuideJob = useCallback(
+    (job: LaborGuideJob) => {
+      const key = `labor-${keyCounterRef.current++}`
+      keysRef.current = [key, ...keysRef.current]
+      setLaborItems((prev) => [
+        {
+          ...makeEmptyLabor(defaultLaborRate),
+          description: job.description,
+          hours: job.hours,
+          total: job.hours * defaultLaborRate,
+        },
+        ...prev,
+      ])
+    },
+    [setLaborItems, defaultLaborRate]
+  )
+
   const addServiceAtEnd = useCallback(() => {
     const key = `labor-${keyCounterRef.current++}`
     keysRef.current = [...keysRef.current, key]
@@ -301,6 +336,15 @@ export function LaborEditor({
       {hasPresets && onOpenPresets && (
         <IconActionButton label={t('fromPresets')} icon={Layers} onClick={onOpenPresets} />
       )}
+      {vehicleId && (
+        <LaborGuideButton
+          vehicleId={vehicleId}
+          vehicleLabel={vehicleLabel}
+          onAdd={addGuideJob}
+          onAddPart={onAddGuidePart}
+          stock={guideStock}
+        />
+      )}
       <IconActionButton label={t('addLabor')} icon={Plus} onClick={addLaborAtStart} />
       <IconActionButton label={t('addService')} icon={Wrench} onClick={addServiceAtStart} />
       {onAddFinding && openObservationsCount > 0 && onShowExistingObservations ? (

+ 17 - 1
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -1,6 +1,7 @@
-import type { ReactNode } from 'react'
+import { type ReactNode, useMemo } from 'react'
 import { PartsEditor } from '../service-edit/PartsEditor'
 import { LaborEditor } from '../service-edit/LaborEditor'
+import { servicePartFromFluid } from '../labor-guide/guide-lines'
 import { ConcernsSection } from '../service-edit/ConcernsSection'
 import { NotesSection } from '../service-edit/NotesSection'
 import { PaymentsSection } from '../service-detail/PaymentsSection'
@@ -91,6 +92,11 @@ export function DetailsLeftColumn({
   payCode = null,
 }: DetailsLeftColumnProps) {
   const tClock = useTranslations('timeTracking.job')
+  // What the labor guide matches a fluid against, priced the way a pick from stock is here.
+  const guideStock = useMemo(
+    () => ({ parts: inventoryParts, pricing: { defaultMarkupPercent, markupAppliesToInventory } }),
+    [inventoryParts, defaultMarkupPercent, markupAppliesToInventory]
+  )
   // Which concerns somebody has actually looked at. Counted here rather than
   // queried, because the findings are already loaded for the section below.
   const answeredCounts = findings.reduce<Record<string, number>>((counts, finding) => {
@@ -169,6 +175,16 @@ export function DetailsLeftColumn({
         onAddFinding={onAddFinding}
         openObservationsCount={openObservationsCount}
         onShowExistingObservations={onShowExistingObservations}
+        vehicleId={record.vehicle?.id ?? null}
+        vehicleLabel={
+          record.vehicle
+            ? `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
+            : undefined
+        }
+        guideStock={guideStock}
+        onAddGuidePart={(line) =>
+          formState.dirtySetPartItems((prev) => [servicePartFromFluid(line), ...prev])
+        }
       />
       <JobClockSection
         serviceRecordId={record.id}

+ 18 - 1
src/features/vehicles/Components/service-page/modern/ModernDetails.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
+import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Receipt } from 'lucide-react'
@@ -24,6 +24,7 @@ import { paymentStatusColors, paymentStatusLabels } from '../../service-detail/t
 import { ConcernsSection } from '../../service-edit/ConcernsSection'
 import { InvoiceDetailsSection } from '../../service-edit/InvoiceDetailsSection'
 import { LaborEditor } from '../../service-edit/LaborEditor'
+import { servicePartFromFluid } from '../../labor-guide/guide-lines'
 import { NotesSection } from '../../service-edit/NotesSection'
 import { PartsEditor } from '../../service-edit/PartsEditor'
 import { ScheduleTimesSection } from '../../service-edit/ScheduleTimesSection'
@@ -133,6 +134,12 @@ export function ModernDetails(props: ModernDetailsProps) {
   } = props
   const t = useTranslations('service')
   const tClock = useTranslations('timeTracking.job')
+  // What the labor guide matches a fluid against, priced the way a pick from stock is here.
+  const { inventoryParts, defaultMarkupPercent, markupAppliesToInventory } = props
+  const guideStock = useMemo(
+    () => ({ parts: inventoryParts, pricing: { defaultMarkupPercent, markupAppliesToInventory } }),
+    [inventoryParts, defaultMarkupPercent, markupAppliesToInventory]
+  )
   const tConcerns = useTranslations('service.concerns')
   const router = useRouter()
 
@@ -365,6 +372,16 @@ export function ModernDetails(props: ModernDetailsProps) {
                     onAddFinding={props.onAddFinding}
                     openObservationsCount={props.openObservationsCount}
                     onShowExistingObservations={props.onShowExistingObservations}
+                    vehicleId={record.vehicle?.id ?? null}
+                    vehicleLabel={
+                      record.vehicle
+                        ? `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
+                        : undefined
+                    }
+                    guideStock={guideStock}
+                    onAddGuidePart={(line) =>
+                      formState.dirtySetPartItems((prev) => [servicePartFromFluid(line), ...prev])
+                    }
                   />
 
                   <PartsEditor

+ 110 - 0
src/integrations/openlaborproject/manifest.ts

@@ -0,0 +1,110 @@
+import { CHECK_PLAN_JOB } from '@/features/integrations/Lib/repair-data-contract'
+import { DEFAULT_PREFS } from '@/features/integrations/Lib/repair-prefs'
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * The variable that holds a key the server uses for every workshop, for
+ * when Torqvoice pays for Open Labor Project on the workshops' behalf.
+ * Unset, every workshop brings its own key. Set, a workshop that leaves its
+ * key empty uses this one, and answers fetched with it serve every workshop.
+ */
+export const SERVER_KEY_ENV = 'OPEN_LABOR_PROJECT_API_KEY'
+
+/**
+ * Open Labor Project: book times and fluid capacities, mostly for
+ * US-market vehicles (a 2014 Ford Kuga came back as Australian data), through
+ * the workshop's own API key. A lookup happens when
+ * somebody opens the labor guide on a work order or a quote; the one job
+ * that runs on its own is the daily check that stored answers may still be
+ * kept.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'openlaborproject',
+  name: 'Open Labor Project',
+  category: 'repair',
+  countries: ['US', 'CA'],
+  logo: '/images/integrations/openlaborproject.svg',
+  docs: '/docs/integrations/openlaborproject',
+  auth: {
+    type: 'api-key',
+    fields: [
+      {
+        key: 'apiKey',
+        label: 'apiKey',
+        type: 'password',
+        required: true,
+        help: 'apiKeyHelp',
+        serverKeyEnv: SERVER_KEY_ENV,
+        serverKeyHelp: 'apiKeyServerHelp',
+      },
+    ],
+  },
+  capabilities: ['repair.labor', 'repair.fluids'],
+  settings: [
+    {
+      key: 'minConfidence',
+      type: 'select',
+      label: 'minConfidence',
+      help: 'minConfidenceHelp',
+      default: DEFAULT_PREFS.minConfidence,
+      options: [
+        { value: 'all', label: 'confidenceAll' },
+        { value: 'medium', label: 'confidenceMedium' },
+        { value: 'high', label: 'confidenceHigh' },
+        { value: 'oem', label: 'confidenceOem' },
+      ],
+    },
+    {
+      key: 'hoursFrom',
+      type: 'select',
+      label: 'hoursFrom',
+      help: 'hoursFromHelp',
+      default: DEFAULT_PREFS.hoursFrom,
+      options: [
+        { value: 'book', label: 'hoursBook' },
+        { value: 'high', label: 'hoursHigh' },
+      ],
+    },
+    {
+      key: 'adjustPercent',
+      type: 'number',
+      label: 'adjustPercent',
+      help: 'adjustPercentHelp',
+      default: DEFAULT_PREFS.adjustPercent,
+    },
+    {
+      key: 'rounding',
+      type: 'select',
+      label: 'rounding',
+      help: 'roundingHelp',
+      default: DEFAULT_PREFS.rounding,
+      options: [
+        { value: 'none', label: 'roundNone' },
+        { value: '0.1', label: 'roundTenth' },
+        { value: '0.25', label: 'roundQuarter' },
+        { value: '0.5', label: 'roundHalf' },
+      ],
+    },
+    {
+      key: 'fluids',
+      type: 'boolean',
+      label: 'fluids',
+      help: 'fluidsHelp',
+      default: DEFAULT_PREFS.fluids,
+    },
+    {
+      key: 'units',
+      type: 'select',
+      label: 'units',
+      help: 'unitsHelp',
+      default: DEFAULT_PREFS.units,
+      options: [
+        { value: 'both', label: 'unitsBoth' },
+        { value: 'metric', label: 'unitsMetric' },
+        { value: 'us', label: 'unitsUs' },
+      ],
+      showWhen: { key: 'fluids', equals: true },
+    },
+  ],
+  schedules: [{ job: CHECK_PLAN_JOB, everyMinutes: 24 * 60 }],
+}

+ 567 - 0
src/integrations/openlaborproject/server.ts

@@ -0,0 +1,567 @@
+import { repairDataJobs, settleKeyCheck } from '@/features/integrations/Lib/repair-data'
+import {
+  type ConnectorContext,
+  type ConnectorServer,
+  type FluidSpec,
+  type LaborTime,
+  type RepairDataAnswer,
+  type RepairDataConfidence,
+  RepairDataKeyRejected,
+  type RepairDataQuery,
+  type RepairDataReply,
+  type RepairDataVariant,
+} from '@/features/integrations/Lib/types'
+import { manifest, SERVER_KEY_ENV } from './manifest'
+
+/**
+ * Open Labor Project: GET /api/v1/labor-times and /api/v1/fluid-specs with
+ * the key in an x-api-key header, answered as { data, meta, error }.
+ *
+ * Verified against the live service on 2 November 2026 with a free key:
+ * - make, model (both as the service's lower-case slugs, "ford", "f-150")
+ *   and year give every engine and drivetrain of that model year under
+ *   data.engines. A vin instead, or an engine slug or vehicle_id beside the
+ *   model, gives one variant under data.vehicle with the list beside it.
+ * - job=<slug> on labor-times and q=<system> on fluid-specs narrow the list
+ *   to that one job or system.
+ * - A free key gets every job and system listed but almost all of them
+ *   "locked", values null; asking for one alone unlocks it. A paid key gets
+ *   every value at once.
+ * - A model the service does not know is 404 MODEL_OR_VARIANT_NOT_FOUND, a
+ *   bad key 401 INVALID_API_KEY, a call with no vehicle 400 MISSING_PARAMS.
+ * - Each row carries a confidence: oem_matched or oem_verified, high, medium,
+ *   or estimated, which the service says is AI-derived and not checked
+ *   against a maker's guide.
+ * - Replies say cache-control: private, no-store, and a free key has ten
+ *   calls a day. Chris at Open Labor Project confirmed on 3 November 2026
+ *   that every paid plan may keep what it fetched for as long as it stays
+ *   subscribed.
+ * - Every reply names the key's plan in x-ratelimit-tier: "hobbyist" for a
+ *   free key. A whole-model reply on a free key also carries meta.freeTier.
+ *   That is what decides whether an answer may be stored.
+ *
+ * Requests go out with plain fetch, not the platform's retrying client: a
+ * person is waiting on the dialog, and a key that has used its day's calls
+ * is not helped by being asked again.
+ */
+const BASE_URL = 'https://openlaborproject.com/api/v1'
+const TIMEOUT_MS = 20_000
+
+/** The connection test's lookup: one job on a vehicle the service has listed since launch. */
+const TEST_QUERY = new URLSearchParams({
+  make: 'ford',
+  model: 'f-150',
+  year: '2018',
+  engine: '5.0l-v8-coyote',
+  job: 'water-pump',
+}).toString()
+
+interface Envelope {
+  data?: unknown
+  meta?: Record<string, unknown>
+  /** A model it does not know comes with the slugs it does know for that make. */
+  error?: { code?: string; message?: string; availableModels?: unknown } | null
+}
+
+/** Plan names Open Labor Project gives a free key. */
+const FREE_TIERS = new Set(['hobbyist', 'free'])
+
+/**
+ * Whether the key's plan lets the answer be kept: a paid tier named in the
+ * header, and no sign of the free tier in the body. A reply that names no
+ * plan is not kept.
+ */
+export function keepAllowed(headers: Headers, body: Envelope): boolean {
+  const tier = headers.get('x-ratelimit-tier')?.trim().toLowerCase()
+  if (!tier || FREE_TIERS.has(tier)) return false
+  return !(body.meta && 'freeTier' in body.meta)
+}
+
+/** One engine and drivetrain as the service describes it. */
+interface RawVariant {
+  vehicleId?: string | null
+  make?: string
+  model?: string
+  engine?: string
+  engineSlug?: string
+  drivetrain?: string | null
+}
+
+export interface RawLaborTime {
+  job?: string
+  jobSlug?: string
+  category?: string
+  hours?: number | null
+  lowRange?: number | null
+  highRange?: number | null
+  confidence?: string | null
+  locked?: boolean
+}
+
+export interface RawFluidSpec {
+  system?: string
+  systemType?: string
+  fluidType?: string | null
+  fluidSpec?: string | null
+  capacityQuarts?: number | null
+  capacityLiters?: number | null
+  drainRefillQuarts?: number | null
+  drainRefillLiters?: number | null
+  changeIntervalMiles?: number | null
+  changeIntervalKm?: number | null
+  notes?: string | null
+  confidence?: string | null
+  locked?: boolean
+}
+
+/** Make and model as the service spells them in its URLs: "Mercedes-Benz" is mercedes-benz. */
+export function slug(value: string): string {
+  return value
+    .toLowerCase()
+    .normalize('NFD')
+    .replace(/[̀-ͯ]/g, '')
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+}
+
+/**
+ * Words other languages use where the service's slugs say "series" or
+ * "class": a Norwegian or Dutch shop writes BMW "3-Serie", a German one
+ * "3er" or Mercedes "C-Klasse".
+ */
+const MODEL_WORDS: [RegExp, string][] = [
+  [/\b(\d)er\b/g, '$1 series'],
+  [/\bseri(?:e|en)\b/g, 'series'],
+  [/\breihe\b/g, 'series'],
+  [/\bklasse?\b/g, 'class'],
+  [/\bclasse\b/g, 'class'],
+  [/\bclase\b/g, 'class'],
+]
+
+/**
+ * The model the way the service likely spells it: a chassis code in
+ * brackets left out and the local word for "series" or "class" in English.
+ * "3-Serie (E9*)" is 3-series, "C-Klasse" is c-class.
+ */
+export function modelSlug(model: string): string {
+  let s = model
+    .toLowerCase()
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '')
+    .replace(/\([^)]*\)/g, ' ')
+  for (const [pattern, to] of MODEL_WORDS) s = s.replace(pattern, to)
+  return slug(s)
+}
+
+/**
+ * The service's own slug for a model the workshop wrote its own way, picked
+ * from the slugs a "not found" reply lists: the one that reads the same
+ * once punctuation is set aside, else the longest one the name starts with
+ * ("5-Serie F11" is 5-series). Null when none fits.
+ */
+export function resolveModel(model: string, available: readonly string[]): string | null {
+  const own = modelSlug(model).replace(/-/g, '')
+  if (!own) return null
+  let best: string | null = null
+  for (const candidate of available) {
+    const key = modelSlug(candidate).replace(/-/g, '')
+    if (!key) continue
+    if (key === own) return candidate
+    const longer = !best || key.length > modelSlug(best).replace(/-/g, '').length
+    if (key.length >= 2 && own.startsWith(key) && longer) best = candidate
+  }
+  return best
+}
+
+export function confidenceOf(value: unknown): RepairDataConfidence | null {
+  if (typeof value !== 'string') return null
+  const v = value.toLowerCase()
+  if (v.startsWith('oem')) return 'oem'
+  if (v === 'high' || v === 'medium') return v
+  if (v === 'estimated' || v === 'low') return 'estimated'
+  return null
+}
+
+function num(value: unknown): number | null {
+  return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+function str(value: unknown): string | null {
+  return typeof value === 'string' && value.trim() ? value.trim() : null
+}
+
+export function mapLaborTime(raw: RawLaborTime): LaborTime | null {
+  const id = str(raw.jobSlug)
+  const name = str(raw.job)
+  if (!id || !name) return null
+  const hours = num(raw.hours)
+  return {
+    id,
+    name,
+    category: str(raw.category) ?? 'misc',
+    hours,
+    low: num(raw.lowRange),
+    high: num(raw.highRange),
+    confidence: confidenceOf(raw.confidence),
+    locked: raw.locked === true || hours === null,
+  }
+}
+
+export function mapFluidSpec(raw: RawFluidSpec): FluidSpec | null {
+  const id = str(raw.systemType)
+  const system = str(raw.system)
+  if (!id || !system) return null
+  const capacityLiters = num(raw.capacityLiters)
+  const capacityQuarts = num(raw.capacityQuarts)
+  const fluid = str(raw.fluidType)
+  return {
+    id,
+    system,
+    fluid,
+    spec: str(raw.fluidSpec),
+    capacityLiters,
+    capacityQuarts,
+    drainRefillLiters: num(raw.drainRefillLiters),
+    drainRefillQuarts: num(raw.drainRefillQuarts),
+    intervalKm: num(raw.changeIntervalKm),
+    intervalMiles: num(raw.changeIntervalMiles),
+    notes: str(raw.notes),
+    confidence: confidenceOf(raw.confidence),
+    locked:
+      raw.locked === true || (fluid === null && capacityLiters === null && capacityQuarts === null),
+  }
+}
+
+/**
+ * The engine's id. A one-system fluids reply leaves engineSlug out, so the
+ * engine asked for stands in, else the name the way the service writes its
+ * slugs ("2.0L I4" is 2.0l-i4).
+ */
+function engineIdOf(raw: RawVariant, asked: string | undefined): string | null {
+  const own = str(raw.engineSlug)
+  if (own) return own
+  if (asked) return asked
+  const name = str(raw.engine)
+  return name ? name.toLowerCase().replace(/\s+/g, '-') : null
+}
+
+function variantOf<T>(
+  raw: RawVariant,
+  list: unknown,
+  map: (row: never) => T | null,
+  askedEngine?: string
+): RepairDataVariant<T> | null {
+  const engineId = engineIdOf(raw, askedEngine)
+  if (!engineId) return null
+  const rows = Array.isArray(list) ? list : []
+  return {
+    engineId,
+    drivetrain: str(raw.drivetrain),
+    vehicleId: str(raw.vehicleId),
+    engine: str(raw.engine) ?? engineId,
+    items: rows.map((row) => map(row as never)).filter((x): x is T => x !== null),
+  }
+}
+
+/**
+ * Either shape of reply in one: the whole model year under data.engines, or
+ * one variant under data.vehicle. Exported for the contract test.
+ */
+export function toAnswer<T>(
+  data: unknown,
+  listKey: 'laborTimes' | 'fluidSpecs',
+  map: (row: never) => T | null,
+  askedEngine?: string
+): RepairDataAnswer<T> | null {
+  if (!data || typeof data !== 'object') return null
+  const d = data as Record<string, unknown>
+  if (Array.isArray(d.engines)) {
+    const variants = (d.engines as Record<string, unknown>[])
+      .map((e) => variantOf(e as RawVariant, e[listKey], map))
+      .filter((v): v is RepairDataVariant<T> => v !== null)
+    if (variants.length === 0) return null
+    return { make: str(d.make) ?? '', model: str(d.model) ?? '', variants }
+  }
+  if (d.vehicle && typeof d.vehicle === 'object') {
+    const vehicle = d.vehicle as RawVariant
+    const variant = variantOf(vehicle, d[listKey], map, askedEngine)
+    if (!variant) return null
+    return { make: str(vehicle.make) ?? '', model: str(vehicle.model) ?? '', variants: [variant] }
+  }
+  return null
+}
+
+/** The workshop's own key, else the one the server holds for every workshop, when it holds one. */
+function apiKey(ctx: ConnectorContext): string {
+  const own = typeof ctx.credentials.apiKey === 'string' ? ctx.credentials.apiKey.trim() : ''
+  return own || process.env[SERVER_KEY_ENV]?.trim() || ''
+}
+
+/** The model, or the variant asked for, is not in the service's list. */
+class NotFound extends Error {
+  constructor(
+    code: string,
+    /** The slugs the service does know for the make. */
+    readonly availableModels: string[]
+  ) {
+    super(code)
+  }
+}
+
+/**
+ * Any other refusal of what was asked, such as a VIN the service decodes
+ * but has no data for (400 MISSING_PARAMS). Kept apart so a VIN attempt
+ * that ends here reads as "not in the guide" rather than as a failure.
+ */
+class Refused extends Error {}
+
+function availableModelsOf(body: Envelope): string[] {
+  const list = body.error?.availableModels
+  return Array.isArray(list) ? list.filter((m): m is string => typeof m === 'string') : []
+}
+
+/**
+ * One call, and whether its answer may be kept. A model the service does
+ * not know throws NotFound for the caller to try the VIN, a refused key
+ * throws RepairDataKeyRejected, and every other refusal is put in words the
+ * person at the dialog can act on.
+ */
+async function call(
+  ctx: ConnectorContext,
+  path: string,
+  params: URLSearchParams
+): Promise<{ body: Envelope; keep: boolean }> {
+  let res: Response
+  try {
+    res = await fetch(`${BASE_URL}/${path}?${params}`, {
+      headers: { 'x-api-key': apiKey(ctx), Accept: 'application/json' },
+      signal: AbortSignal.timeout(TIMEOUT_MS),
+      cache: 'no-store',
+    })
+  } catch (err) {
+    await ctx.log('error', 'Open Labor Project could not be reached', {
+      path,
+      error: err instanceof Error ? err.message : String(err),
+    })
+    throw new Error('Open Labor Project could not be reached, try again in a moment')
+  }
+  const body = (await res.json().catch(() => ({}))) as Envelope
+  if (res.ok) return { body, keep: keepAllowed(res.headers, body) }
+  const code = body.error?.code ?? ''
+  const message = body.error?.message?.trim() ?? ''
+  if (res.status === 404) throw new NotFound(code || 'not found', availableModelsOf(body))
+  if (res.status === 401 || code === 'INVALID_API_KEY') {
+    await ctx.log('error', 'Open Labor Project rejected the API key', { path })
+    throw new RepairDataKeyRejected('Open Labor Project rejected the API key')
+  }
+  if (res.status === 429) {
+    await ctx.log('warn', 'Open Labor Project: call limit reached', { path, code })
+    throw new Error(
+      message
+        ? `Open Labor Project: ${message}`
+        : 'Open Labor Project: this key has used all its calls for now, try again later'
+    )
+  }
+  const words = message
+    ? `Open Labor Project: ${message}`
+    : `Open Labor Project answered HTTP ${res.status}`
+  if (res.status === 400 || res.status === 422) throw new Refused(words)
+  await ctx.log('error', 'Open Labor Project lookup failed', { path, status: res.status, code })
+  throw new Error(words)
+}
+
+/** The query string: the vehicle, then the variant and the one item when asked for. */
+function paramsFor(
+  query: RepairDataQuery,
+  by: 'model' | 'vin',
+  itemParam: 'job' | 'q',
+  modelId?: string
+) {
+  const params = new URLSearchParams()
+  if (by === 'vin' && query.vin) {
+    params.set('vin', query.vin)
+  } else {
+    params.set('make', slug(query.make))
+    params.set('model', modelId ?? modelSlug(query.model))
+    params.set('year', String(query.year))
+  }
+  if (query.variant) {
+    params.set('engine', query.variant.engineId)
+    if (query.variant.vehicleId) params.set('vehicle_id', query.variant.vehicleId)
+  }
+  if (query.item) params.set(itemParam, query.item)
+  return params
+}
+
+/** A plausible VIN, which the service decodes when it cannot find the model by name. */
+function vinOf(query: RepairDataQuery): string | null {
+  const vin = query.vin?.trim().toUpperCase()
+  return vin && /^[A-HJ-NPR-Z0-9]{17}$/.test(vin) ? vin : null
+}
+
+/**
+ * By model name first, so one call answers for every engine, under the
+ * name another lookup already found when there is one. A name the service
+ * does not know is matched against the slugs its "not found" reply lists
+ * and asked once more. Only a model it knows under no name we can find is
+ * tried by VIN, and a VIN it cannot place is "not found", not a failure.
+ * Every attempt costs a call and an unknown vehicle can take the service
+ * half a minute to answer, so nothing is asked that cannot change the
+ * answer.
+ */
+async function lookup<T>(
+  ctx: ConnectorContext,
+  query: RepairDataQuery,
+  path: string,
+  listKey: 'laborTimes' | 'fluidSpecs',
+  itemParam: 'job' | 'q',
+  map: (row: never) => T | null
+): Promise<RepairDataReply<T>> {
+  const vin = vinOf(query)
+  // A model nobody knows says nothing about the plan, so it is not kept.
+  let keep = false
+  const where = { make: query.make, model: query.model, year: query.year }
+
+  const ask = async (
+    by: 'model' | 'vin',
+    modelId?: string
+  ): Promise<RepairDataAnswer<T> | null> => {
+    const reply = await call(
+      ctx,
+      path,
+      paramsFor({ ...query, vin: vin ?? undefined }, by, itemParam, modelId)
+    )
+    keep = reply.keep
+    const body = reply.body
+    const found = toAnswer(body.data, listKey, map, query.variant?.engineId)
+    const region = str(body.meta?.region)
+    const answer: RepairDataAnswer<T> | null = found
+      ? {
+          ...found,
+          matchedBy: by,
+          modelId: by === 'model' ? modelId : found.model ? modelSlug(found.model) : undefined,
+          ...(region ? { region } : {}),
+        }
+      : null
+    const locked = answer?.variants.reduce(
+      (n, v) => n + v.items.filter((i) => (i as { locked?: boolean }).locked).length,
+      0
+    )
+    // Make, model and year only: the VIN identifies one vehicle and stays out of the log.
+    await ctx.log('info', answer ? `${path}: match` : `${path}: no match`, {
+      by,
+      ...where,
+      modelId: by === 'model' ? modelId : null,
+      variants: answer?.variants.length ?? 0,
+      locked: locked ?? 0,
+      item: query.item ?? null,
+      kept: keep,
+    })
+    return answer
+  }
+  const notFound = (by: 'model' | 'vin', modelId?: string) =>
+    ctx.log('info', `${path}: not found`, { by, ...where, modelId: modelId ?? null })
+  const none = (): RepairDataReply<T> => ({ answer: null, keep })
+
+  const first = query.modelId ?? modelSlug(query.model)
+  try {
+    const answer = await ask('model', first)
+    return answer ? { answer, keep } : none()
+  } catch (err) {
+    if (!(err instanceof NotFound)) throw err
+    await notFound('model', first)
+    // Found under this name before, or a name the service lists: it knows
+    // the model and has nothing for this year or engine. A VIN cannot help.
+    if (query.modelId || err.availableModels.includes(first)) return none()
+    const other = resolveModel(query.model, err.availableModels)
+    if (other && other !== first) {
+      try {
+        const answer = await ask('model', other)
+        return answer ? { answer, keep } : none()
+      } catch (again) {
+        if (!(again instanceof NotFound)) throw again
+        await notFound('model', other)
+        return none()
+      }
+    }
+  }
+
+  if (!vin) return none()
+  try {
+    const answer = await ask('vin')
+    return answer ? { answer, keep } : none()
+  } catch (err) {
+    if (!(err instanceof NotFound || err instanceof Refused)) throw err
+    await notFound('vin')
+    return none()
+  }
+}
+
+/**
+ * Whether the key is still on a plan that may keep its answers, from one
+ * cheap call. Null when the reply does not say, such as when the key has
+ * used its calls for the day.
+ */
+async function plan(ctx: ConnectorContext): Promise<{ keep: boolean | null }> {
+  const res = await fetch(`${BASE_URL}/labor-times?${TEST_QUERY}`, {
+    headers: { 'x-api-key': apiKey(ctx), Accept: 'application/json' },
+    signal: AbortSignal.timeout(TIMEOUT_MS),
+    cache: 'no-store',
+  })
+  const body = (await res.json().catch(() => ({}))) as Envelope
+  if (res.status === 401 || body.error?.code === 'INVALID_API_KEY') {
+    throw new RepairDataKeyRejected('Open Labor Project rejected the API key')
+  }
+  if (!res.headers.get('x-ratelimit-tier')) return { keep: null }
+  return { keep: keepAllowed(res.headers, res.ok ? body : {}) }
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    if (!apiKey(ctx)) return { ok: false, message: 'Open Labor Project: an API key is required' }
+    // One job on one vehicle the service lists: a single call, and one it
+    // can answer. A call with no vehicle shows up as an error on their side.
+    // A key that has used its calls for the day is still a good key, and a
+    // vehicle gone from their list says nothing about the key either.
+    try {
+      const res = await fetch(`${BASE_URL}/labor-times?${TEST_QUERY}`, {
+        headers: { 'x-api-key': apiKey(ctx), Accept: 'application/json' },
+        signal: AbortSignal.timeout(TIMEOUT_MS),
+        cache: 'no-store',
+      })
+      if (res.status === 401) {
+        await settleKeyCheck(ctx, manifest, 'rejected')
+        return { ok: false, message: 'Open Labor Project rejected the API key' }
+      }
+      if (res.ok || res.status === 404 || res.status === 429) {
+        // A reply that names the plan settles what may stay stored.
+        if (res.headers.get('x-ratelimit-tier')) {
+          const body = (await res.json().catch(() => ({}))) as Envelope
+          await settleKeyCheck(
+            ctx,
+            manifest,
+            keepAllowed(res.headers, res.ok ? body : {}) ? 'paid' : 'free'
+          )
+        }
+        return { ok: true }
+      }
+      return { ok: false, message: `Open Labor Project answered HTTP ${res.status}` }
+    } catch {
+      return { ok: false, message: 'Open Labor Project could not be reached' }
+    }
+  },
+
+  laborTimes: (ctx, query) =>
+    lookup(ctx, query, 'labor-times', 'laborTimes', 'job', (row: RawLaborTime) =>
+      mapLaborTime(row)
+    ),
+
+  fluidSpecs: (ctx, query) =>
+    lookup(ctx, query, 'fluid-specs', 'fluidSpecs', 'q', (row: RawFluidSpec) => mapFluidSpec(row)),
+
+  repairDataPlan: plan,
+
+  jobs: repairDataJobs(manifest, plan),
+}

+ 27 - 1
src/integrations/registry.ts

@@ -21,6 +21,7 @@ import { manifest as nhtsa } from './nhtsa/manifest'
 import { manifest as openai } from './openai/manifest'
 import { manifest as openaiCompatible } from './openai-compatible/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
+import { manifest as openLaborProject } from './openlaborproject/manifest'
 import { manifest as paypal } from './paypal/manifest'
 import { manifest as postmark } from './postmark/manifest'
 import { manifest as quickbooks } from './quickbooks/manifest'
@@ -61,6 +62,7 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: rdw, load: () => import('./rdw/server') },
   { manifest: regcheck, load: () => import('./regcheck/server') },
   { manifest: nhtsa, load: () => import('./nhtsa/server') },
+  { manifest: openLaborProject, load: () => import('./openlaborproject/server') },
   { manifest: twilioSms, load: () => import('./twilio-sms/server') },
   { manifest: vonageSms, load: () => import('./vonage-sms/server') },
   { manifest: telnyxSms, load: () => import('./telnyx-sms/server') },
@@ -122,9 +124,33 @@ function forThisInstall(manifest: ConnectorManifest): ConnectorManifest {
   return out
 }
 
+/**
+ * A key the server holds for every workshop, for a service Torqvoice pays
+ * for on their behalf. While its variable is set the workshop's own key is
+ * optional: left empty, the server's is used.
+ */
+export function withServerKeys(
+  manifest: ConnectorManifest,
+  env: Record<string, string | undefined> = process.env
+): ConnectorManifest {
+  if (manifest.auth.type !== 'api-key') return manifest
+  const held = (f: { serverKeyEnv?: string }) =>
+    Boolean(f.serverKeyEnv && env[f.serverKeyEnv]?.trim())
+  if (!manifest.auth.fields.some(held)) return manifest
+  return {
+    ...manifest,
+    auth: {
+      ...manifest.auth,
+      fields: manifest.auth.fields.map((f) =>
+        held(f) ? { ...f, required: false, help: f.serverKeyHelp ?? f.help } : f
+      ),
+    },
+  }
+}
+
 const ENTRIES: readonly RegistryEntry[] = ALL_ENTRIES.filter(
   (e) => !(IS_CLOUD && e.manifest.selfHostedOnly)
-).map((e) => ({ ...e, manifest: forThisInstall(e.manifest) }))
+).map((e) => ({ ...e, manifest: withServerKeys(forThisInstall(e.manifest)) }))
 
 const BY_ID = new Map(ENTRIES.map((e) => [e.manifest.id, e]))
 

+ 2 - 0
src/lib/backup/manifest.ts

@@ -391,6 +391,8 @@ export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
   IntegrationLink:
     'Remote ids in a connected system, only valid for the connection that created them.',
   IntegrationLog: 'Connector activity log, rewritten every time a job runs.',
+  RepairDataCache:
+    "Labor times and fluid specs kept from a repair data source while the workshop's key is on a paid plan. They belong to the connection, which a backup does not carry, and may only be kept while that plan lasts; the guide fetches them again when needed.",
   OrganizationMember: 'Membership of user accounts; people are restored by inviting them.',
   PlatformMailUsage:
     "Today's count of mail sent through the platform address, for the daily cap; it starts again every day.",

+ 11 - 0
src/lib/backup/tenancy-schema.ts

@@ -525,6 +525,17 @@ export const TENANCY_SCHEMA: SchemaGraph = {
     foreignKeys: {},
     ownerPaths: [],
   },
+  RepairDataCache: {
+    organizationId: false,
+    foreignKeys: {
+      connectionId: {
+        target: 'IntegrationConnection',
+        required: false,
+        relation: 'connection',
+      },
+    },
+    ownerPaths: [['connection']],
+  },
   BackupRun: {
     organizationId: true,
     foreignKeys: {