CarStat.dev
Ndërtoni me AI

Prompt për agjentin tuaj AI të kodimit

Ngjiteni këtë si mesazhin e parë te Claude Code, Cursor, Copilot, Codex ose ChatGPT. I tregon agjentit si sillet vërtet API-ja, përfshirë rastet kufitare që shembujt vetëm nuk i tregojnë.

Çfarë po ndërtoni?
Stack-u
7,220 fjalë · rreth 12.6k tokena
  • Prompti nuk e përmban kurrë çelësin tuaj API. Agjentit i thuhet ta lexojë nga CARS_API_KEY.
  • E drejton agjentin te kontrata e lexueshme nga makina në https://api.lotarius.com/docs-api/openapi.json, që të gjenerojë tipe në vend që të hamendësojë.
  • Përfundon me një përkufizim të përfundimit, që t'i kërkoni agjentit të kontrollojë punën e vet kundrejt tij.

Vetë prompti mbetet në anglisht: agjentët e kodimit i ndjekin më mirë udhëzimet teknike në anglisht.

cars-api-brief.md
# Cars API integration brief

You are integrating the Cars API: a read-only REST API with used and new vehicle listings aggregated from car marketplaces, dealer sites and auctions all over the world. Follow this brief exactly. Where it is silent, the OpenAPI contract is the source of truth. Do not invent fields or behaviour from a single example.

**Task:** Build a complete integration: a scheduled inventory sync into our database, a server-side search API with filters for our UI, and vehicle detail pages that show every listing plus inspection and history reports.
**Stack:** Use the language, framework and conventions already present in this repository.

## 1. Connection
- Base URL: https://api.lotarius.com
- OpenAPI 3.0.3 contract: https://api.lotarius.com/docs-api/openapi.json. Download it first and generate types/clients from it.
- Every endpoint is GET and returns JSON. Send the header `Accept: application/json` on every request.
- Authenticate with the `api_key` query parameter on every request. Read the key from the environment variable `CARS_API_KEY`. Never hardcode, commit or log it, and never log full request URLs (they contain the key).
- The API sends no CORS headers. Call it only from server-side code and expose your own backend endpoints to any frontend. The key must never reach a browser bundle.
- Send `lang` explicitly on every request (en, ru, uk, pl, bg or sq). It localizes location names, equipment names, report labels and validation messages. It does not localize enum `name` keys or brand/model names. If your UI runs in another language, send `lang=en` and translate on your side.
- Source IDs are specific to the key. Call GET /domains at startup and cache it. Never hardcode domain IDs taken from examples.

## 2. Data model
- A **car** is one physical vehicle. `listings[]` are its ads on source sites. One car can have several listings, including several from the same site.
- Car fields: id, vin, brand{id,name}, model{id,name}, badge, year, month, body_type, fuel, transmission, transmission_steps, color, drive_wheels, emission_standard, market_origin, steering_wheel_position, engine_volume, power_hp, cylinders, doors, seats, battery_capacity_kwh, option_ids[], has_inspections, has_history_reports, hash, created_at, updated_at. Detailed endpoints add inspections[] and vehicle_history_reports[].
- Listing fields: external{id, domain{id,name}, url}, archived, document_id{id,name}, source_data (source-owned passthrough object, keep as-is), title (language→text map, pick the user language with a fallback), price{price, currency{id,name}, negotiable, history[]}, odometer (km), images[{downloaded?, preview?, original?}], video (array or null), location_details, is_auction, current_bid, auction_at, availability, condition, damage, second_damage, airbag_state, seller_type, is_leasing, keys_available, is_manufacturer_certified, has_history_reports, has_registration, description, option_ids[], created_at, last_seen_at, updated_at.
- Four separate ID namespaces. Never pass one where another is expected:
  - car `id`: opaque string (e.g. "l_eCJb-8zB_XcJ-z6Y7oZWjw"). Store as text.
  - listing `external.id`: the source site's ad ID, a string. Unique only together with `external.domain.id`.
  - domain ID: the source site, from /domains.
  - brand and model IDs: catalogue IDs from /brands and /models/{brand_id}.
- Identify a listing by the pair (external.domain.id, external.id). Never assume listings[0] is the listing you want.
- A missing field or `null` means "not provided by the source". It is never 0, false or an empty string. Keep explicit zeros.
- The contract is additive: new fields can appear at any time. Ignore unknown fields and never fail validation because of them. Keep the raw JSON if you persist data.
- Categorical values are objects like {"id": 2, "name": "electric"}. `name` is a stable key to translate in the UI, not display text. Filters take the numeric id.
- Prices are in each listing's own currency and are never converted by the API. Never add, average, compare or sort prices across different currencies client-side.
- `hash` is a SHA-256 of the car without timestamps. If it is unchanged, skip the write.
- For display, read `listings[].location_details`: country{id,name,iso}, region{id,name}, district{id,name}, place{id,name}, each nullable. A region can equal the place (same id): deduplicate by id. Do not use the legacy `location` field in new code.
- Images: prefer `downloaded`, then `original`, then `preview`. Any of them can be missing and `images` can be empty.

## 3. Endpoints
- GET /search: filtered search over active cars. Lightweight: report flags only, no report arrays. `page`, `perPage` (2–50, default 50).
- GET /search/count: same filters as /search, returns {"count": n}. Exact.
- GET /cars: full export through a cursor, with full reports. `limit` 1–2000 (default 500), `scroll_time` 1–15 minutes (default 10), `domains[]`, `is_leasing`.
- GET /cars/{car_id}: one car with full reports, in `data`.
- GET /cars/vin/{vin}: exact VIN, 16–19 letters/digits, case-insensitive.
- GET /listing/{domain_id}/{listing_id}: the car that contains this listing, with only that listing's reports.
- GET /domains: enabled sources, a bare array [{id, name, base_url}].
- GET /brands?query=: bare array [{id, name, icon_url}]. Up to 10,000 without query, 50 with query.
- GET /models/{brand_id}?query=: bare array [{id, name, generations[{name, from, to}]}]. Unknown brand returns [].
- GET /countries, GET /countries/{id}, GET /locations, GET /locations/{id}: geographic dictionaries. Lists return {data, meta{per_page, has_more, next_after_id}}.
- GET /car-option-sections and GET /car-options: equipment. Resolve option_ids with /car-options?ids=1,2,3.
- Exact lookups (/cars/{car_id}, /cars/vin/{vin}, /listing/...) can return a recently sold car for about a month; its listings then have `archived: true`. /search and /cars only return active cars.

## 4. Pagination
- /search uses page numbers. Keep `page * perPage <= 10000`; never request beyond that. `data` can contain perPage + 1 items: slice it to perPage. `meta.total` is capped at 10000, so call /search/count with the same filters for the real total. Stop when `links.next` is null. Build your own page links; do not follow `links`/`meta.path` URLs from the response.
- /cars uses a cursor. Call it first without `scroll_id`, then request `next_url` exactly as returned (it already contains the key, filters and lang). Stop when `scroll_id` is null and `data` is empty; `next_url` is then absent. Keep `domains[]` and `is_leasing` the same for the whole pass (changing them returns 422). The cursor expires `scroll_time` minutes after the last request; on 403/404 "your scroll id was expired or it's invalid", start a new pass. Cursors cannot be resumed.
- /countries and /locations use an ID cursor: pass `meta.next_after_id` as `after_id` with unchanged filters until `meta.has_more` is false. `per_page` 1–1000 (default 100). Results are ordered by id, not relevance. Batch lookups with `ids[]=1&ids[]=2` (up to 100).
- /car-options in flat mode uses `page` and `per_page` (1–100, default 25), with `page * per_page <= 10000`.

## 5. Filter syntax
- Applies to /search and /search/count. /cars only accepts `domains[]` and `is_leasing`.
- Serialize arrays in PHP bracket format; comma-separated values are not split. Lists repeat the key: `fuels[]=2&fuels[]=3`.
- Brand with models: `brands[0][id]=88&brands[0][models][]=573`. More brands: `brands[1][id]=...`.
- Country with regions: `countries[0][id]=208&countries[0][regions][]=1841610` (ids from /countries and /locations).
- Inclusive ranges: year_from/year_to (1800–3000), price_from/price_to, odometer_from/odometer_to (km), engine_volume_from/engine_volume_to, power_hp_from/power_hp_to. `seats` is exact.
- `has_inspections`, `has_history_reports`, `keys_available`, `is_manufacturer_certified` accept only 1 or 0 ("true" returns 422). `is_leasing` accepts true/false/1/0; false also matches listings without the flag.
- `currency` (3-letter code, default usd) applies to price filters and price sorting only. It does not convert returned prices. `currency_id` (integer) overrides `currency`. An unknown code returns 422 "currency is invalid"; `sort=cheaper|expensive` without a resolvable currency returns 422.
- Price filters and price sorts use prices the API converted into every currency. Only listings with a price above 0 in a known currency take part: listings without a price never match a price filter and sort last. `current_bid` is never used for price filters or sorts. A card can therefore show a price in EUR while the user filtered in USD; say so in the UI ("filtered in USD, shown in the listing currency").
- `seller_type` (single integer): 1 = individual, 2 = dealer.
- Listing-level filters (domains, keys_available, damages, airbags, is_manufacturer_certified, has_history_reports, price) are each matched by any active listing of the car, not necessarily the same listing. The listing you show on a card may not be the one that matched a filter.
- `damages[]` matches a listing whose `damage` OR `second_damage` is in the list.
- `query` is a full-text match over listing titles in all languages (phrase first, then words). When `query` is the only filter, the API may turn recognised brand/model words into structured filters and keep the rest as text, so counts can change when other filters are added.
- There is no filter for auction-only listings, market_origin, listing age, "with photos only" or generation. Do not invent them; `generations[]` from /models is display-only.
- `sort`: newest (default), oldest, cheaper, expensive, odometer_asc, odometer_desc, year_asc, year_desc, auction_nearest.
- Sort semantics: every sort first puts cars that have an active listing with downloaded photos, so no order is "pure". newest/oldest use the listing `created_at` (when the source published it, or when we first saw it), not the car year. cheaper/expensive use the lowest/highest converted price in the selected currency, missing prices last. odometer_* use listing odometer; year_* use car year; auction_nearest orders by `auction_at` ascending.
- `query`: full-text search, max 200 characters. `vin`: partial VIN match (use /cars/vin/{vin} for an exact VIN). `domains[]`: only IDs from /domains.
- Omit unused filters entirely. Empty values are validated and can return 422.
- An empty result is HTTP 200 with `data: []` or `count: 0`. Show an empty state, not an error.

## 6. Errors and retries
- Body shapes: `{"error": "..."}` for access and not-found errors; `{"errors": {"field": ["..."]}}` for 422 validation (a `message` may or may not be present); `{"message": "...", "your_value": n, "default_value": n}` for /cars limit errors. 5xx responses can be HTML. Read whichever field is present.
- 400: search engine rejected the query, or /cars limit/scroll_time too large.
- 403: key or access problem. Messages: "please add your api_key in get param", "wrong api key", "your api subscription has expired", "your api subscription is not active", "ip address is not in whitelist" (includes `ip`), "you don't have any data in your subscription". Also an invalid /cars cursor. Do not retry; surface a clear message.
- 404: car, VIN, listing or dictionary entry not found in the key's sources, or an expired cursor.
- 422: validation. Map `errors` keys to the fields that caused them.
- 503: search temporarily unavailable. Wait for `Retry-After` seconds.
- Retry only 429, 503 and other 5xx, with exponential backoff, at most 4 attempts, honouring `Retry-After`. Use a 30–60 s timeout. There is no fixed per-minute rate limit, but do not hammer the API: reuse dictionaries from cache and run exports sequentially.

## 7. Inventory sync
- Run a full /cars pass 2–3 times a day with `limit=2000` and an explicit `lang`. Process batches sequentially and fetch the next one well before the cursor expires.
- Upsert cars by `id`; skip unchanged `hash`. Store listings keyed by (domain_id, listing_id). Keep the raw car JSON next to your normalized columns.
- Record every car id seen in the pass. Only after the pass finishes successfully, mark cars that were not seen as sold/archived. Never archive anything after a failed or interrupted pass.
- Sold cars disappear from /cars and /search immediately, but /cars/{car_id} and /listing/{domain_id}/{listing_id} still return them with `archived: true` for about a month. Use that to show a "Sold" state.
- Refresh dictionaries (/domains, /brands, /models, /car-option-sections, /car-options, /countries, /locations) separately and less often, cached per `lang`.
- Log progress (batch number, cars processed, `total`) without the URL. `total` can be null.

## 8. Search UI
- Expose your own backend search endpoint that maps UI filters to /search; the browser never talks to the Cars API.
- Fill filter controls from the API: /brands then /models/{brand_id} as a dependent select, /domains for sources, the enum IDs below for categorical filters, and /countries then /locations?country_id=C&feature_class=A&feature_code=ADM1 for regions.
- Call /search/count in parallel with /search for the result counter.
- Stop pagination at page * perPage = 10000 and explain that the user should narrow the filters.
- Result card: brand, model, badge, year; first image; price in its own currency with the negotiable flag; odometer in km; location_details names; source domain name; flags for inspection/history availability; auction data (current_bid, auction_at) when is_auction is true. For cars with several listings, show the listing count and the lowest price per currency.
- Debounce filter and text changes by 350 ms, cancel stale requests, and keep the filter state in the URL (details under Presentation rules).
- Distinct states: loading, empty result, validation error on a field, access error, temporary failure with retry.
- Cache search and detail responses per API key/account only. Mark your own responses `Cache-Control: no-store, private`; never share one key's results with another account. When the key changes, clear cached domains, option sections, option labels and brands.

## 9. Vehicle page and reports
- Load /cars/{car_id}. When the user comes from a specific source listing, use /listing/{domain_id}/{listing_id}. /search results never contain report bodies; `has_inspections` and `has_history_reports` are only availability flags.
- Listing switcher: price, mileage, photos, location, seller data and history belong to one listing. Never borrow a missing value from another listing. Show `archived: true` listings as sold.
- Equipment: resolve `option_ids` with /car-options?ids=... and group by section from /car-option-sections.

### Inspection (`inspections[]`)
- Show the conclusion first: conclusions.overall_result_key and the accident/water/fire/chemical/biohazard status keys, plus inspected_at, valid_until, source and limitations.
- Group `checked_items` under `inspection_blocks` by block_id/block_key and sort by sort_order. Keep unmatched items in a fallback group.
- `result_key` has six states: ok, issue, not_checked, not_applicable, not_equipped, unknown. unknown and not_checked are never "ok"; not_equipped is not a defect. Unknown future keys stay neutral.
- Translate every `*_key` value with `translations.terms[type][key]` from the same report, falling back to the key and then `source_label`. `source_label`, `source_code` and `description` are source evidence in the report language: show them, never use them as translation keys. `id` is only unique inside one report.
- Show every item's part, location, result, issue_type_keys, severity_key, damage_rank_key, description and recommendation when present. Use the visual_* coordinates only for a known diagram; otherwise show a grouped list.
- Link media to findings via evidence_media_ids and checked_item_id. Keep report media separate from listing photos.

### Vehicle history (`vehicle_history_reports[]`)
- A report belongs to exactly one listing: vehicle_ref.domain_id + vehicle_ref.listing_id. Show it only for that listing and never copy it to other listings of the same car.
- Show coverage (period_start, period_end, section_keys) and limitations near the top.
- `summary` counters: show explicit zeros, hide null. owner_change_count counts changes, not owners.
- `checks[]` as status rows. result_key: present, not_present, unknown, not_applicable.
- `events[]` as a timeline sorted by occurred_at. Respect date_precision_key (datetime, day, month, year, unknown): do not invent a day for a month-precision date.
- Amounts use summary.currency or events[].currency. Never mix currencies in one total or chart.
- An empty or missing report does not prove a clean history. With several reports, show each separately; never merge events across reports.

### Report fields that are easy to miss
- `translations`: language, fallback_language, missing[], taxonomy_available, source_text_translated. When `source_text_translated` is false, source labels and descriptions are untranslated; say so next to them.
- Inspection: participants[] (role_key, name, source_label, company_id, user_id), uuid, conclusions.evaluation_score, conclusions.notes, updated_at, block counts (checked_count, passed_count, issue_count, not_checked_count), access.attach_to_listing.
- History: summary.flood_partial_loss_count, summary.vehicle_damage_claim_cost and third_party_damage_claim_cost (in summary.currency), events[].organization_role_key, vehicle_ref.site_car_id.
- Examples of event `type_key` values: initial_registration, registration, owner_change, insurance_claim, insurance_coverage_gap, maintenance, statutory_inspection, recall. Examples of check `type_key` values: accident_history, flood_total_loss_history, theft_history, lien_history, odometer_rollback, rental_use_history, business_use_history. The taxonomy grows: render unknown keys with their translation or source_label, never drop them.

## Presentation rules
These rules come from our production catalog and the API contract. Follow them unless the user asks otherwise.

### Titles
- Card title: `brand model year` when brand or model exists. Otherwise use the listing `title` map in the user language, then `en`, then any non-empty value (sources can have only `ko` or `zh`), without appending the year.
- Some sources send the title map as a JSON-encoded string; parse it before use.
- Keep `badge` out of the title; show it as a spec. A subtitle such as `brand · year` must not repeat a brand that is already in the title.

### Prices
- Auction (`is_auction: true`): show `current_bid` labelled "Current bid"; if it is missing, show `price` labelled "Auction price". Otherwise show `price` labelled "Price" and `current_bid` as a separate row when present. Keep an explicit 0 bid.
- A missing price reads "Price on request", never 0 and never "$0". Show `negotiable: true` as a "Negotiable" tag.
- Currency is `price.currency.name` in upper case. Never convert, never rank or sort prices across currencies client-side.
- `price.history`: drop entries with a missing or negative amount or an unparseable `created_at`, sort newest first. Use the listing currency only when `currency_id` is null or equals `price.currency.id`; otherwise show the amount with its currency ID instead of guessing. Show history only with at least two points including the current price.

### Auction listings
- `is_auction` is a nullable boolean. `current_bid` and `auction_at` are only filled for auction listings. `current_bid` has no currency of its own: show it in the listing `price.currency`.
- Fields that do not exist, so never invent them: lot number (use `external.id` as the lot/listing ID), buy-now or reserve price, auction status or result, sale date. The only sale state is `archived`.
- Auction-relevant condition fields: `damage`, `second_damage`, `airbag_state`, `keys_available`, `condition`, `document_id` (a title/document type as {id, name}).
- `auction_at` is a date-time. Show date and time in the user's time zone; if it is in the past, label it as the past auction date rather than "upcoming".
- Card and car page must use the same price rule (see Prices): an auction shows "Current bid", else "Auction price"; a regular listing shows "Price" plus the bid as a separate row when present.

### Photos and video
- Per image, prefer `downloaded`, then `original`, then `preview`. Accept only http(s) URLs, keep the API order, drop exact duplicates.
- Load images with `referrerpolicy="no-referrer"`. Once a URL fails, hide it everywhere instead of showing a broken image.
- Use only the selected listing's `images` and `video`. Report photos from inspections and history stay in their own gallery.

### Location
- Display `listings[].location_details`: place, district, region, country, deduplicated by id (a region can equal its place). Use `country.iso` for a flag.
- The legacy `location` field is a fallback only. With `location.position` lat/lon you may link to a map; never reverse-geocode or guess a city.

### Specs
- Read a field from the selected listing first, then from the car. Units: `engine_volume` in cc, `power_hp` in hp, `odometer` in km, `battery_capacity_kwh` in kWh. Never append a unit twice.
- Hide missing values instead of printing "N/A". Show booleans as Yes/No only when they are explicitly true or false.
- Show the VIN in upper case; hide placeholder values such as n/a, none or unknown.
- Categorical objects ({id, name}): translate `name` in your UI and show the id only as a last fallback.

### Languages
- Enum `name` values (e.g. `minor_dents_scratches`, `sport_car`) are English keys: keep your own label table per UI language, keyed by the enum id, and fall back to a humanised key.
- Brand and model names are not translated. Titles are a language→text map; location, equipment and report labels follow `lang`.
- Keep your UI language and the API `lang` separate: for a UI language the API does not support, request `lang=en` and translate the UI yourself.

### Search screen behaviour
- Send brands as `brands[i][id]` with optional `brands[i][models][]`; deduplicate; no models means all models of that brand.
- Label source filters as "name (id)" from /domains and cache them per key.
- Run the search about 350 ms after the last filter change, reset to page 1, and ignore responses that arrive after a newer request.
- Last page = min(ceil(count / perPage), floor(10000 / perPage)), with count from /search/count run in parallel. Slice `data` to perPage.
- A full VIN (16–19 letters and digits) with no other filters goes to /cars/vin/{vin}, which also finds recently sold cars. Otherwise send `vin` to /search, which matches partially and only returns active cars.

### Filter panel
- Multi-select: sources (domains), brands, models per brand, colors, regions. Single choice in the UI (the API also accepts arrays): body type, fuel, condition, availability, emission standard, drive wheels, damage. Single integer: transmission (1 automatic, 2 manual), steering wheel position, airbags, seller type.
- Tri-state Any / Yes / No for keys_available, is_manufacturer_certified, has_inspections, has_history_reports: Any omits the parameter, Yes sends 1, No sends 0.
- Ranges with two inputs (from/to): price with a currency selector, year (1960 to the current year is a good slider range), odometer (0–400,000 km, step 1,000), engine volume (0–8,000 cc), power (0–1,000 hp). Seats is an exact number; quick pills 2/4/5/6/7/8 help.
- Location: country first, then its ADM1 regions from /locations?country_id=C&feature_class=A&feature_code=ADM1. Regions appear only after a country is chosen, changing the country clears regions, and regions are sent only together with their country.
- Brand → model: load /models/{brand_id} for every selected brand in parallel and drop stale responses. Removing a brand removes its model selections. A brand with no models selected means all its models.
- Order brands with locale collation and pin a "Popular brands" group on top. Order models naturally (numbers numerically, e.g. 2 Series before 10 Series) and put "Other" last. `icon_url` on /brands can be null or missing: fall back to a letter avatar.
- Source filter: label each source as its name (or hostname from `base_url`) with the id in small text; /domains accepts `search` for a filter-as-you-type box (debounce 300 ms). Cache labels per key.
- Active filters as removable chips with human labels (source hostname, brand and model names, country and region names, translated enum labels, numbers with locale grouping). Show the count of active filters and a "Clear all" that also resets to page 1.
- Offer a lookup by source listing: domain + source listing ID → /listing/{domain_id}/{listing_id}, and a VIN box (see Search screen behaviour).

### URL state
- Keep every filter, the sort, page size and page in the query string so a search can be shared and reloaded. Omit defaults (sort newest, page 1, your default page size, default currency).
- Validate and clamp values read from the URL: years 1800–3000, positive numbers only, booleans only 0/1, sort only a known value, page at most floor(10000 / perPage). Drop anything invalid instead of sending it to the API.
- Update the URL with replace (not push) while the user types; never put an API key in the URL.
- A car page URL should carry the car id plus the selected listing (`domain_id` and `listing_id`) and the open tab, so a link opens exactly the same view.
- Result header: "N results" from /search/count; page buttons first, last, current ±1 with ellipses. Page size choices must stay within perPage 2–50.

### Result cards
- Choose the listing a card represents: an active listing whose domain is in the `domains[]` filter, else the first active listing, else the first listing. Take the card photo, price, odometer, location, condition and flags from that one listing only, and show "N listings" when there are more.
- /search returns every visible listing of the car, including archived ones; the car itself always has at least one active listing.
- Card facts (show what exists, in this order): mileage, year, transmission, fuel, engine volume (cc), power (hp), drive wheels, body type, color, seats. A list view can add a second column: condition, damage, second_damage, keys available, airbag state, availability, seller type, manufacturer certified, auction date, location.
- Availability is an enum (in_stock, in_transit, on_order). When it is null show nothing; do not default to "Available".
- Photos: up to 6 in the card carousel with a "+N" remainder, the first eager with high fetch priority and the rest lazy, a neutral "No photo" placeholder when none load. Swipe on touch devices. Clicking the card opens the car page.
- Badges: "Inspection" from the car `has_inspections`, "History" from the chosen listing `has_history_reports`. Each badge links straight to that tab of the car page.
- Numbers: amounts with the UI locale grouping, at most 2 decimals, followed by the ISO currency code in upper case ("25,000 USD"); never guess a currency symbol. Mileage as "N km"; 0 km is a real value.

### Car page layout
- Open the page immediately with the data you already have from the card, marked as loading, then replace it with the /cars/{car_id} (or /listing/...) response. If that request fails, keep the card data and show a warning ("Showing the data available on the card"). A deep link to a listing that does not exist shows "Vehicle not found" with a way back.
- Header title: the selected listing `title` in the user language, then en, then any value; fall back to the card title. Below it an identifier row with VIN, source listing ID and domain ID, each copyable.
- Production date: car `month` (1–12) as a short localized month before the year, e.g. "Mar 2020".
- Group the specs: Listing (price or bid, currency, availability, condition, auction, auction date, negotiable, source). Vehicle (VIN, year, brand, model, badge, body type, color, market origin). Technical (odometer, engine volume, cylinders, power, fuel, transmission, transmission steps, drive wheels, doors, seats, battery capacity, emission standard, steering wheel position). Source (document, archived, leasing, damage, second damage, airbag state, registration, keys, manufacturer certified, seller type, listing ID, domain ID, source URL).
- Seller description (`description`) is plain text in the source language: strip decorative symbols, turn long dash runs into line breaks, trim lines, and collapse it after about 280 characters with a "Show more" control. Never render it as HTML.
- Map: show country, a map link built from `location.position` lat/lon (5 decimals) when present; never geocode.
- Gallery: a main image with "n / total", thumbnails (for example 18 with a "+N" cell), a lightbox, keyboard navigation, `referrerpolicy="no-referrer"`, and a "Sold" stamp on archived listings. Offer "Open listing on the source" (`external.url`, new tab, rel="noopener noreferrer"). Play `video` URLs in a separate video block when present.
- Listing switcher entries: preview image, source name, price or "No price", `#external.id`, Active/Sold, and the last update date. When opened from a card, select the listing from the URL, else the active listing whose URL matches the card, else the first active, else the first.
- Switching listing resets the gallery position, expanded description, option search, selected inspection zone and open dialogs, and updates the URL with replace.
- Tabs: Overview, Prices (only with more than one listing), Equipment, Inspection, History, raw JSON. Show counts on tabs once loaded and a clear empty state per tab ("No inspection reports", "No history reports", "No equipment"). If the Prices tab disappears after a switch, fall back to Overview.

### Listings on a car page
- Key every listing by `${external.domain.id}:${external.id}`. Two listings on the same domain are two separate listings.
- Default listing: the one the user came from (domain_id + listing_id in your URL), else the first listing with `archived: false`, else `listings[0]`.
- `archived: true` means sold. Show a "Sold" notice with the source name and the last seen date (`last_seen_at`, then `updated_at`), and offer a switch to an active listing of the same car when one exists. A deep link may open a sold listing on purpose.
- Show a listing selector and a price comparison only when there is more than one listing; list active listings first.
- Show "N listings on M sites": the listing count and the number of distinct `domain.id` values are different numbers.
- The selected listing never borrows price, URL, photos, odometer, location, condition, seller or options from another listing. Car-level identity (brand, model, year, VIN, specs) is shared.
- Keep a raw JSON view of the whole car for support and debugging.

### Equipment
- With several listings, show the selected listing's `option_ids`; use the car-level `option_ids` only when no listing is selected. Deduplicate IDs.
- Names come from /car-options, sections from `section_id` via /car-option-sections; unknown sections go to "Other", last. Sort alphabetically and add a local search for long lists.
- If names fail to load, keep the IDs visible instead of hiding the section.
- /car-options?ids= is paginated even with ids: send `per_page` equal to the number of ids (max 100) and split longer lists into batches of 100, otherwise options after the 25th disappear. Unknown or unapproved ids are simply missing from the answer.
- Option `name`, `description` and `location` can be null when the account languages exclude every translation. Fallback order: requested lang, the default language, en, any allowed language; show the id when all are missing.
- Car-level `option_ids` is the union of the car and all its visible listings, which is why a selected listing must use its own `option_ids`.
- Options also have `location`, `description` and `parent_option_id`/`parent_option`; show location and description under the name, nest children under their parent, and let the local search match all of them.

### States, errors and accessibility
- Errors use `role="alert"`; mask any API key in error text. Empty results offer "Clear filters", failures offer "Retry". If a detail request fails, keep the data already on screen and show a warning.
- Use skeletons while loading, `aria-busy` on loading regions, `aria-live` for result counts, `role="tablist"`/`"tab"` for tabs, and respect `prefers-reduced-motion`.
- Hide technical report keys (`*_id`, `*_hash`, schema and mapper versions) from end users; keep them in the raw JSON view.
- Scope every report to the selected listing through `vehicle_ref.domain_id` + `vehicle_ref.listing_id`.

### Do not copy these shortcuts
- Showing "$0" or 0 for a missing price or bid; defaulting a null availability to "Available".
- Using the legacy `location` ids as display text, hardcoding country ids, or extracting addresses from descriptions with regular expressions.
- Deriving "owners" as owner changes + 1, relabelling registration changes as plate changes, or showing only `reports[0]`.
- Showing a report on a listing it does not belong to, or dropping 0 km odometer readings.
- Reading fields that are not in the contract (for example msrp, sale_date, report_url) or estimating values such as a yearly mileage average.
- Page sizes above 50 (perPage=96 returns 422).

## Report rules in full
Apply every rule below when you render `inspections` and `vehicle_history_reports`.

### Report loading, identity and lifecycle
- /search omits full report arrays: omission means not loaded, not no reports. Load car details or GET /listing/{domain_id}/{listing_id} before displaying report contents. Keep loading, request failure, no report and a report with empty sections as distinct states; allow retry.
- has_inspections is a root-car availability flag, not proof of an inspection for every listing. Root has_history_reports is discovery metadata; use listings[].has_history_reports for a source-specific badge. Flags are not report counts, quality scores or conclusions.
- GET /cars, /cars/{car_id} and /cars/vin/{vin} collect reports for active listings visible to the API key. GET /listing/{domain_id}/{listing_id} scopes reports to that exact pair. A retained archived listing and an archived report are different concepts; an archived vehicle lookup does not guarantee car-level report arrays.
- Match vehicle_ref.domain_id AND vehicle_ref.listing_id to the selected listing, including when two listings share a domain or VIN. Preserve listing IDs as strings. An absent/incomplete vehicle_ref is unresolved provenance, not permission to assign the report to all listings; use exact-listing context or display it separately as unscoped.
- Show all reports as separate selectable sections or tabs with source, report_id and dates. A default selected report must not hide the others or merge their events, counts, conclusions or evidence. Deduplicate only repeated copies of the same scoped report, never by VIN, title or date alone.
- On listing/report changes, reset report-specific filters, selected map markers, timeline/odometer selection and open media. Scope caches to credentials/access, listing identity and language; invalidate on authorization changes and refresh. Cancel or ignore stale requests so an old response cannot replace the new selection.
- Keep the original public payload available for inspection alongside normalized display data. Prefer documented snake_case arrays. Legacy aliases found in catalog adapters (inspection, history_reports, timeline, records, owner_changes, damage_records, source_payload) are compatibility fallbacks, not additional guaranteed public fields; do not concatenate aliases and count the same evidence twice.
- Accept additive fields, unfamiliar canonical keys, missing optional fields and empty arrays without crashing. Preserve schema_version, mapper_contract_version and source.mapper_version for diagnostics; do not infer a fixed provider schema from one sample.

### Inspection interpretation and interaction requirements
- Keep publication status/visibility, technical conclusions and checked-item results separate. Read conclusions.overall_result_key and each accident/water/fire/chemical/biohazard status independently. Local issue/OK counts describe returned checks only; they cannot replace the source conclusion or certify the entire vehicle as accident-free.
- Preserve all six result states: ok, issue, not_checked, not_applicable, not_equipped, unknown. Missing/future states stay neutral. Not equipped is not a defect; not applicable and not checked are not passes. Count checked_items once by result, distinguish those counts from damaged map zones and source claims, and do not invent a score or denominator.
- Group checked_items with inspection_blocks using block_id where available and canonical block_key; honor block sort_order and retain unmatched items in a readable fallback group. Keep empty source blocks and their explicit outcomes distinguishable from missing checks. Provide block/status filters and text search without changing report-wide totals; show a separate no-filter-matches state.
- Each finding should expose part/location, result, all issue_type_keys, severity, damage_rank, accident_relevance, description, recommendation and clarification when provided. These are separate dimensions: scratches, painting, repair or replacement do not alone prove a major accident. Preserve original-language evidence alongside translated canonical labels.
- source_claims are source assertions and remain distinct from checked_items and conclusions. A claim may supplement a diagram only when it actually identifies a part; never turn a generic accident-free claim into green body panels. Avoid double-counting a claim and a check describing the same finding.
- Show measurements with their own type, value_number/value_text, unit, result and description; retain explicit zero. Show public diagnostic_errors with system, code, status, severity and description, separately from measurements and limitations. Diagnostic codes are evidence, not translation keys; do not discard diagnostic_errors as if it were internal processing_info.
- Use visual_map_id/visual_marker_id with visual_x, visual_y, visual_width, visual_height and visual_shape_key only when the corresponding map and coordinate convention are known. Zero coordinates are valid. Never assume pixels or percentages from a sample; retain a grouped findings list when map metadata is absent or unsupported.
- A schematic fallback may separate exterior, interior, underbody, tires and wheel rims. Prefer canonical part/location mapping; source-specific label mappings are compatibility rules, not a universal taxonomy. Keep left/right and front/rear distinct, and do not map door trim, handles, switches, glass or seals onto the outer door panel. Map tires separately from rims, including all four wheels and an explicitly reported spare.
- Aggregate map state conservatively: any issue wins; otherwise any unknown/unassessed fact prevents a green zone; green requires explicit ok evidence; a zone with no facts stays muted. Keep individual results accessible, including conflicts. Zone totals are not item totals. Unmapped facts remain visible in the list.
- Map tooltips/details must include the exact part, defects and original description, not just Issue/OK. Support keyboard focus and touch selection as well as hover, show text with colors, and keep popovers within the viewport. Link findings to evidence by report-local evidence_media_ids and media/files checked_item_id or block_id; tolerate unresolved references.
- Show inspected_at, valid_until, source and limitations near the conclusion. Missing validity is unknown, not unlimited validity; a refreshed system_updated_at does not mean a new inspection. vehicle_snapshot describes the report-time vehicle, while the main car/listing supplies current vehicle data.

### History interpretation, tables and charts
- Use checks[].type_key for meaning and translation; id is only a report-local identity. Preserve result_key, count, description, evidence_type_key and verification_key. present/not_present describe the named check within source coverage; unknown/not_applicable stay neutral. A positive use/ownership record is not automatically damage, and Major accident history must never be relabeled as all accident history.
- Keep summary aggregates separate from detailed evidence. Display only explicitly supplied finite, nonnegative counters; preserve zero and distinguish it from null, absent, empty or invalid values. Do not infer missing owner/claim/theft/loss totals from events.length. owner_change_count is changes, not total owners; registration_change_count is not automatically plate-number changes.
- Do not interpret no events, no claims, zero reported accidents or not_present as a guarantee of a clean vehicle outside the report coverage. coverage.completeness_key and section_keys describe source coverage, not a vehicle health score or a percentage of checks passed. Show period_start/period_end and limitations prominently.
- Preserve occurred_at, ended_at and date_precision_key. Show year-only and month-only dates at that precision, do not invent day/time, and avoid timezone shifts for calendar dates. Unknown/invalid dates remain visible as undated. Sort by actual source dates with stable tie handling, never by localized date strings; allow newest/oldest ordering.
- Distinguish created_at (source creation), checked_at (source check), source.imported_at and system_updated_at (API refresh). A refresh is not a new accident or maintenance event. Event status_key has its own semantics and must not be interpreted as the check result vocabulary.
- Preserve every event, including initial registration, registration/owner/use changes, insurance start/gaps, claims, maintenance, statutory inspections, recalls, comparative quotes, total loss, flood and theft. Specialized tables may supplement a complete timeline; unfamiliar event types need a generic detail view. A statutory inspection history event is not a full inspections[] report.
- Ownership rows can expose subtype, transaction/use details, organization, mileage, evidence and verification. Claims can expose amount_total, amount_insurance_benefit, amount_parts, amount_labor, amount_painting, subtype and processing details. Maintenance rows retain organization/mileage/details; recalls retain status and any correction method, period or target device details. Preserve remaining details[].key and typed values with unit_key using a generic fallback.
- Use summary.currency for summary costs and events[].currency for event amounts; the listing price currency and search currency do not convert report amounts. Preserve explicit zero and decimal precision. Do not add insurance benefit to repair total, assume components sum to total, replace zero with a fallback, or fill missing cost with zero. Unknown currency must remain unspecified.
- Claim charts and largest-claim comparisons require comparable amounts in the same currency; separate currencies or omit the comparison. Clearly identify the amount being compared. Do not silently substitute benefit for total or infer a total from incomplete components. Every chart must have readable values/table evidence, including zero amounts.
- Build odometer views from finite, nonnegative mileage_km values and usable event dates, preserving 0 km. Keep source/date precision and conflicting readings; remove only duplicate display points, not underlying events. Require at least two distinct usable readings for a trace; otherwise show available readings and an insufficient-data state. Label an evenly spaced chart as ordered readings, or use proportional time spacing.
- A decreasing mileage sequence is a derived rollback signal, not proof of fraud and not a replacement for the source odometer_rollback check. Do not invent ordering between uncertain dates or merge readings from unrelated reports. Preserve the reported unit and distinguish report-time mileage from current listing mileage.
- Insurance gap bars need valid ordered boundaries and a valid coverage span. Clip display bars to coverage but preserve full source dates; an unknown end must remain unknown. Empty gaps do not prove continuous coverage. Recall announcements, completion and unknown status stay distinct: do not count every recall as open or every unrecognized status as completed.
- Keep summary findings traceable to their checks/events. A compact top-findings preview must link to the full evidence and disclose truncation. Display report/source identity, vehicle_ref, report dates, coverage, evidence and verification so users can assess provenance without exposing internal processing fields or private source identities.

### Report media, resilience and acceptance checks
- Keep inspection/history evidence distinct from listing photos/videos. Group photos, videos and files, honor sort_order, retain descriptions and use report-local IDs for relationships. A missing URL is unavailable evidence, not a working attachment; local_path/server_id alone are not public download URLs and must not be guessed into one.
- Open the clicked photo/video after grouping/filtering by mapping its identity to the actual lightbox slide index. Keep documents as attachments, tolerate failed/unsupported media, and avoid duplicate slides without losing finding-to-media links. Video playback follows browser policy; close/stop it when changing report or dismissing the viewer, and restore focus.
- Treat source labels/descriptions as plain text, including HTML-based lightbox captions; validate external link schemes and open external documents with noopener/noreferrer. A media failure must not hide the report or its textual evidence. Preserve access restrictions when refreshing/loading details.
- Verify: two listings sharing a VIN/domain; switching while a request is pending; multiple reports; archived listing lookup; omitted versus empty report arrays; failed request/retry; mismatched or missing vehicle_ref; repeated aliases/IDs across reports; unfamiliar keys and source-language descriptions.
- Verify: all six inspection states; mixed ok/unknown/issue in one zone; unmapped parts; door trim versus panel; four damaged rims; source claims versus checks; zero measurements/coordinates; missing evidence links; diagnostics and limitations; filters with no matches; photo/video/file ordering and escaped captions.
- Verify: absent versus explicit-zero counts/costs/mileage; owner changes versus owners; major versus general accident scope; mixed currencies and fractional amounts; incomplete claim breakdown; partial/invalid dates; undated events; one odometer reading; conflicting/declining mileage; unknown recall state; incomplete gap boundaries; unknown event/detail types and access to all source reports.
- These are implementation requirements derived from the documented report contract and catalog edge cases. Do not copy catalog shortcuts as API guarantees: selecting reports[0] only, permissive missing-reference matching, id-based check labels, blanket green conclusions, omitted diagnostics, dropped zero mileage or cross-currency comparisons must not become the integration contract.

## Enum IDs (send the id in filters; responses return {id, name})
- body_types[]: 1=sedan, 2=wagon, 3=coupe, 4=pickup, 5=suv, 6=cabrio, 7=van, 11=hatchback, 12=roadster, 13=limousine, 20=liftback, 22=hearse, 27=sport_car
- colors[]: 1=silver, 2=purple, 3=orange, 4=green, 5=red, 6=gold, 8=brown, 9=grey, 10=turquoise, 11=blue, 12=bronze, 13=white, 14=cream, 15=black, 16=yellow, 17=beige, 18=pink, 100=two_colors
- fuels[]: 1=diesel, 2=electric, 3=hybrid, 4=gasoline, 5=gas, 6=flexible, 7=hydrogen, 8=ethanol
- transmission: 1=automatic, 2=manual
- seller_type: 1=individual, 2=dealer
- steering_wheel_position: 1=left, 2=right
- airbags: 1=intact, 2=deployed, 3=missing
- damages[]: 1=side, 2=theft, 3=burn, 4=electric, 5=vandalized, 6=water, 7=top_roof, 8=transmission, 9=suspension, 10=biohazard, 11=cash_for_clunkers, 12=repossession, 13=rollover, 14=all_over, 15=engine, 16=frame, 17=front, 18=rear, 19=front_and_rear, 20=hail, 21=mechanical, 22=minor_dents_scratches, 23=vin, 24=normal_wear, 25=rejected_or_partial_repair, 26=storm, 27=stripped, 28=undercarriage
- conditions[]: 1=used, 2=new, 3=damaged
- availabilities[]: 1=in_stock, 2=in_transit, 3=on_order
- emissions[]: 1=euro_1, 2=euro_2, 3=euro_3, 4=euro_4, 5=euro_5, 6=euro_6, 7=zev
- drive_wheels[]: 1=rear, 2=front, 3=all
- transmission_steps (response only): 1=one, 2=two, 3=three, 4=four, 5=five, 6=six, 7=seven, 8=eight, 9=nine, 10=ten, 11=eleven, 12=twelve, 13=thirteen, 14=fourteen, 15=fifteen, 16=sixteen, 17=seventeen, 18=eighteen
- market_origin (response only): 1=europe, 2=usa, 3=china, 4=india, 5=japan, 6=korea, 8=united_kingdom, 9=canada, 10=turkey, 11=russia, 12=south_korea, 13=thailand, 14=australia, 15=indonesia, 16=middle_east, 17=africa, 18=taiwan, 19=mexico, 20=north_korea, 21=oceania, 22=asia, 23=brazil, 24=south_america, 25=north_america

## Definition of done
- The API key is read from CARS_API_KEY and never appears in client code, logs or the repository.
- Every request sends `Accept: application/json` and an explicit `lang`.
- Unknown fields, null values and empty arrays never break parsing or rendering.
- 403, 404, 422 and 503 each produce a distinct, clear message; only 429/5xx are retried.
- Tests: a complete pass archives unseen cars; an interrupted pass archives nothing; an expired cursor restarts the pass; an unchanged hash skips the write.
- Tests: empty result; `data` with perPage + 1 items is sliced; page limit at 10,000; array filters serialize as `key[]=a&key[]=b`; flags send 1/0; a URL with invalid values is clamped; price sort without currency is handled; a card shows one listing's data only.
- Tests: a car with two listings from the same domain; a listing without price; an auction without bid; a history report shown only on its own listing; an unknown result_key; a month-precision event date; more than 100 option ids; a failed detail request keeps the card data.

Before writing code, read the OpenAPI file, then summarize your plan in a few bullets and list any assumption this brief does not cover.