# Festool eKat — Complete Implementation Notes

## What This Is

**Festool eKat** is Festool's spare-parts catalog at `ekat.festool.de`. It lists every Festool product, its exploded assembly diagrams, and the part numbers for each component.

The goal in Cydekick is to scrape this catalog and store it locally so staff can:
1. Browse the catalog (categories → tools → assembly diagrams → parts)
2. Search for a part number directly
3. (Future) link parts to purchase orders / supplier stock

---

## Catalog Platform: DocWare ETK / Quanos Solutions

The catalog is a Java-based SPA called **DocWare ETK** (also branded Quanos Solutions Parts Publisher). Key properties:

- URL: `https://ekat.festool.de/ekat/app` — rendered inside an **iframe** on the host page
- Everything renders client-side via Java applet → JavaScript bridge; the DOM is fully synthetic
- **Element IDs are unstable**: DocWare reassigns element IDs on every navigation render. IDs like `#EQA65216727` are valid only for the current DOM snapshot — they change completely after any navigation
- **Catalog IDs are also session-scoped**: The IDs embedded in `onmouseup` handlers (e.g. `"OQL65947721"`) also change between browser sessions. They cannot be used as stable DB keys.
- No REST API; all data comes from DOM inspection

### English autologin URL
```
https://ekat.festool.de/login/app?forcerestart=true&autologin=true&userId=Kunde-FT_com-en&password=Customer
```

### Catalog structure (corrected)
```
FESTOOL root (17 top-level categories)
  └─ Category (e.g. "Carpentry machines")
       └─ Sub-category (e.g. "Chain mortiser")
            └─ Tool-group category (e.g. "CM 150")   ← has T-Nr. children
                 └─ Tool-product (T-Nr. row)          ← assembly level, e.g. "CM 150 variant"
                      └─ Diagram (e.g. "Cutter unit") ← parts list
                           └─ Part (position, part no., description, price)
```

**CRITICAL**: When the center panel shows a `T - Nr.` column header, the items are **tool-products** (children), NOT tools themselves. The parent item (e.g. "CM 150" category) must be classified as an `EkatCategory`, not an `EkatTool`. This was a previous bug.

---

## 2-Frame Architecture

The Playwright session has **two frames**:
- **Main frame**: `ekat.festool.de` — the host page with login/nav shell
- **Content frame**: `ekat.festool.de/ekat/app` — the actual catalog, matched by `/\/ekat\/app/`

All DOM evaluation must target the **content frame**, not the page:
```javascript
const frame = page.frames().find(f => /\/ekat\/app/.test(f.url()));
frame.evaluate(() => { /* your DOM code */ });
```

---

## DOM Interaction Rules

### Left-panel tree items (categories)
- Have **`onmouseup`** handlers only — no `onclick`
- Must click via `dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))`
- **NOT** via `frame.locator().click()` — that fires a full mouse event sequence which doesn't trigger the mouseup handler correctly for these elements
- Located at x=45–400, y varies, height≥20, width≥50

### Center-table ">" row buttons (navigate into a category/tool)
- Have **`onclick`** + `onmouseup` + `onmousedown`
- Must click via `frame.locator('#id').click({ timeout: 5000 })` — fires all events
- Located at x=1000–1090, y>150 (no upper bound — panel scrolls)

### Center breadcrumbs (navigate back up)
- Located at **y≈28** (top of iframe), x=400–1100
- Have **`onclick`** handlers only
- Must click via `frame.locator('#id').click()` or `el.click()` in JS
- **NOT** via `dispatchEvent(mouseup)` — silently ignored
- Text sorted by x: leftmost = "FESTOOL" (root), rightmost = immediate parent

---

## getCenterRows() y-bounds

The center ">" buttons at x=1000–1090:
- **Lower bound**: `r.top < 150` — KEEP. Excludes toolbar/header buttons at the top of the iframe.
- **Upper bound**: `r.top > 870` — REMOVE. The panel scrolls; items below the fold have r.top > 870 but are valid. Removing this allows all items to be read (fixed W.O. from 7 rows → 29 rows).

---

## Navigation Invariants

### goBack (within processCenter)
The `goBack()` function navigates up one level using the **rightmost center breadcrumb**:
```
At CM 150 assembly level: breadcrumb = [FESTOOL, Carpentry machines, Chain mortiser]
  → click "Chain mortiser" → navigates to Chain mortiser center level
```

### goToRoot (between root categories in main loop)
After finishing each root category, DocWare has reassigned ALL element IDs in the left-panel tree. The stored domIds from session start are stale. Before clicking the next root category:
1. Call `goToRoot()` — clicks the leftmost center breadcrumb (always "FESTOOL") to go back to root
2. Call `getTreeItems(frame)` — re-reads fresh domIds
3. Match by text to find the next category's fresh domId
4. Click using the fresh domId

### reNav (recovery when goBack goes to wrong level)
Some tools have sub-items that cause `goBack` to land at a sub-view instead of the parent's full listing. Detection: after goBack, if `getCenterRows().length < rows.length`, call the `reNav` callback which:
1. `goToRoot()` — navigate to FESTOOL root
2. Re-click the current root category from fresh tree items
3. Execution resumes with the correct full row count

---

## Level Detection (`getLevel`)

The center panel shows different column headers depending on depth:

| Return value | Column headers present | Meaning |
|---|---|---|
| `'parts'` | "Part No." or "Pos" | Parts list (leaf) |
| `'assembly'` | "T - Nr." (with spaces!) | Category containing tool-products |
| `'category'` | neither | Sub-category listing OR tool assembly diagram list |

**CRITICAL**: When `getLevel()` returns `'assembly'`, the **currently-displayed items** are tool-products (T-Nr. rows). The **parent item** that was clicked to get here is a **category**, not a tool. This distinction drives the `processToolChildren` function.

**Regex**: The regex for T-Nr. must match the literal `"T - Nr."` with spaces: `/^T\s*-\s*Nr\.?$/i`

---

## Classification Logic in `processCenter`

```
Click into item X → getLevel() on X's center panel:

  'assembly'  → X is a SUB-CATEGORY (T-Nr. children are tool-products)
                emit('category', X)
                call processToolChildren() to handle T-Nr. rows as tools

  'parts'     → X is a TOOL with a flat parts list (no separate diagram view)
                emit('tool', X) + emit('diagram', synthetic) + extract parts

  'category'  → check childRows:
                  allDigit labels → X is a TOOL, children are diagrams
                                    emit('tool', X), processAssemblies()
                  otherwise       → X is a SUB-CATEGORY
                                    emit('category', X), recurse processCenter()
```

---

## `processToolChildren` Function

Called when `childLevel === 'assembly'`. The browser is already inside the T-Nr. list page.

```
For each T-Nr. row (tool-product):
  1. emit('tool', row)
  2. Click into it (navigate to assembly diagram list)
  3. getLevel():
       'parts'    → synthesise a single diagram, extract parts
       otherwise  → processAssemblies() for the diagram list
  4. goBack() to T-Nr. list level
```

---

## `processAssemblies` Function

Called when a tool's assembly diagram list is displayed. Emits diagram records and extracts parts.

```
For each diagram row:
  1. Click into it (navigate to parts view)
  2. extractDiagramImageUrl() — find largest img in content area
  3. emit('diagram', { ..., diagram_image_url })  ← emitted AFTER click so we have image URL
  4. If level='parts': extractParts(), emit each as 'part'
  5. goBack() to diagram list level
```

Note: diagram is emitted **after** navigating into it (so image URL can be included). If click fails, diagram is emitted without image URL.

---

## Stable DB Keys (semantic updateOrCreate)

DocWare catalog IDs are session-scoped and cannot be used as DB lookup keys. Semantic keys are used instead:

| Table | Lookup key |
|---|---|
| `ekat_categories` | `name + parent_id` |
| `ekat_tools` | `name + category_id` |
| `ekat_diagrams` | `name + tool_id` |
| `ekat_diagram_parts` | `diagram_id + part_number + position` |

`ekat_id` column is still stored (updated each crawl) but is NOT unique — the UNIQUE constraint was dropped in migration `2026_08_24_000001_drop_unique_ekat_id_from_ekat_tables`.

---

## Images — Diagram Images in R2

### What's implemented
When the scraper navigates into a diagram's parts view, it extracts the URL of the largest image in the content area (the assembly diagram image). This URL is emitted as `diagram_image_url` in the NDJSON stream.

The PHP job (`EkatCrawlerJob`) downloads the image and uploads it to R2 at:
```
ekat/diagrams/<uuid>.<ext>
```
The R2 key is stored in `ekat_diagrams.diagram_r2_key`. The blade view checks `diagram_r2_key` first, falls back to `diagram_image_url`.

### What's NOT implemented
- **Tool thumbnail images** (`ekat_tools.thumbnail_r2_key`): No reliable image source found in DocWare. Field is reserved for future use (could fetch from Festool's public website by model number).

### R2 folder convention
```
ekat/diagrams/   ← assembly diagram images
ekat/tools/      ← tool thumbnails (reserved, not yet populated)
```

### Skip images flag
The `skipImages` constructor parameter on `EkatCrawlerJob` (and `skip_images` UI toggle) controls whether diagram images are downloaded and uploaded to R2. When true, `--no-parts` is passed to the scraper which also skips clicking into tools/diagrams entirely (no parts either). So "skip images" = "structure only, no parts or images".

---

## Label Extraction from Center Rows

`getCenterRows()` reads the ">" buttons and finds their label text from nearby `.r-table-cell` elements at x=430–900, within 35px vertically. When multiple cells exist at the same y:
- Prefer cells with **letters** (product names like "CM 150") over pure numbers (T-Nr. like "493955")
- Among lettered cells, prefer **longer** text

---

## Center Signature (Change Detection)

Navigation is detected by watching the IDs of center ">" buttons (x=1000–1090):
```javascript
async function getCenterSig(frame) {
    return frame.evaluate(() =>
        Array.from(document.querySelectorAll('[onmouseup]'))
            .filter(el => { const r = el.getBoundingClientRect(); return r.left > 1000 && r.left < 1090; })
            .map(el => el.id).join(',')
    );
}
```
`waitForNav` polls until this signature changes (max ~8s).

---

## Concurrent Crawl Guard

`EkatCrawlerJob` checks for an already-running crawl at the start of `handle()`:
```php
$alreadyRunning = EkatCrawlRun::where('status', 'running')
    ->where('id', '!=', $this->runId)
    ->exists();
```
If another crawl is running, the new job fails immediately with a clear error message.

---

## Key Files

| File | Purpose |
|---|---|
| `scripts/ekat-scrape.mjs` | Main Playwright crawler. Outputs NDJSON to stdout, progress to stderr |
| `plugins/webkul/festool-ekat/src/Jobs/EkatCrawlerJob.php` | Laravel job that runs the scraper, imports NDJSON, uploads images to R2 |
| `plugins/webkul/festool-ekat/src/Filament/Pages/BrowseEkat.php` | Filament page `/admin/festool-ekat-browse` — category/tool/diagram browser |
| `plugins/webkul/festool-ekat/src/Filament/Pages/ManageEkat.php` | Filament page `/admin/festool-ekat` — crawl management / trigger |
| `plugins/webkul/festool-ekat/src/Models/EkatCategory.php` | Category model |
| `plugins/webkul/festool-ekat/src/Models/EkatTool.php` | Tool model (thumbnail_url, thumbnail_r2_key) |
| `plugins/webkul/festool-ekat/src/Models/EkatDiagram.php` | Assembly diagram model (diagram_image_url, diagram_r2_key) |
| `plugins/webkul/festool-ekat/src/Models/EkatDiagramPart.php` | Part number model |
| `plugins/webkul/festool-ekat/src/Models/EkatCrawlRun.php` | Crawl run tracking (status, progress counts) |

---

## Database Schema

```
ekat_categories
  id, ekat_id (NOT unique — session-scoped), parent_id (self-FK nullable),
  name, level (0=root), sort_order

ekat_tools
  id, ekat_id (NOT unique), category_id (FK), name, model_number, description,
  thumbnail_url, thumbnail_r2_key, raw_data (json)

ekat_diagrams
  id, ekat_id (NOT unique), tool_id (FK), name, sort_order,
  diagram_r2_key, diagram_image_url

ekat_diagram_parts
  id, diagram_id (FK), part_number, position, description, price, currency, is_available,
  hotspot_x (float nullable), hotspot_y (float nullable)

ekat_crawl_runs
  id, status (pending/running/completed/failed), current_step, started_at, completed_at,
  categories_total/done, tools_total/done, diagrams_total/done, error_message,
  tool_limit, skip_images
```

---

## Crawler Script: `scripts/ekat-scrape.mjs`

### Running manually
```bash
# All 17 categories, no parts (structure only — fastest)
node scripts/ekat-scrape.mjs --no-parts

# Limit to first 2 categories for testing
node scripts/ekat-scrape.mjs --limit-cats 2 --no-parts

# Show browser window (headed mode for debugging)
node scripts/ekat-scrape.mjs --limit-cats 1 --no-browser

# Full crawl with parts and images (very slow — hours)
node scripts/ekat-scrape.mjs
```

### Output format (stdout — NDJSON stream)
One JSON object per line, emitted as each record is discovered:
```
{"type":"category","data":{"ekat_id":"LHK...","name":"Carpentry machines","parent_ekat_id":null,"level":0,"sort":0}}
{"type":"category","data":{"ekat_id":"XYZ...","name":"Chain mortiser","parent_ekat_id":"LHK...","level":1,"sort":0}}
{"type":"category","data":{"ekat_id":"ABC...","name":"CM 150","parent_ekat_id":"XYZ...","level":2,"sort":0}}
{"type":"tool","data":{"ekat_id":"...","name":"CM 150 E","category_ekat_id":"ABC...","sort":0}}
{"type":"diagram","data":{"ekat_id":"...","name":"Cutter unit","tool_ekat_id":"...","sort":0,"diagram_image_url":"https://ekat.festool.de/..."}}
{"type":"part","data":{"position":"1","part_number":"123456","description":"...","price_net":"12.34 €","diagram_ekat_id":"..."}}
{"type":"done","data":{}}
```

### Clearing DB before re-crawl
Only needed when the classification logic changes (semantic keys handle incremental re-crawls).
```php
DB::statement('SET FOREIGN_KEY_CHECKS=0');
DB::table('ekat_diagram_parts')->truncate();
DB::table('ekat_diagrams')->truncate();
DB::table('ekat_tools')->truncate();
DB::table('ekat_categories')->truncate();
DB::table('ekat_crawl_runs')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
```

---

## What's Working

- [x] Playwright browser launch with headless Chromium
- [x] English autologin URL navigation
- [x] Left-panel tree item detection and click (mouseup dispatch)
- [x] Center ">" button click (locator.click)
- [x] Level detection (parts / assembly / category)
- [x] `getLevel()` with correct "T - Nr." regex (has spaces)
- [x] `getCenterRows()` with correct y-bounds (lower bound only — upper bound removed to capture scrolled items)
- [x] `getCenterRows()` label extraction (prefers letters over numbers)
- [x] Breadcrumb navigation for goBack (onclick, must use `.click()` not dispatchEvent)
- [x] `goToRoot()` — navigate to FESTOOL root between root categories (fixes stale domIds)
- [x] `reNav` callback — recovery when goBack lands at wrong level
- [x] Full `processCenter` recursion with corrected classification:
  - `'assembly'` → parent is a CATEGORY, `processToolChildren()` handles T-Nr. rows as tools
  - `'parts'` → parent is a flat TOOL, synthesise single diagram
  - `'category'` → recurse as sub-category (or allDigit → tool with diagram children)
- [x] `processToolChildren` — handles T-Nr. rows as tools, then processes their assembly diagrams
- [x] `processAssemblies` — diagram list + diagram image extraction + parts
- [x] `extractDiagramImageUrl` — finds largest img in content area (x=380–1080, min 150×150)
- [x] Diagram image URL emitted in NDJSON stream
- [x] `EkatCrawlerJob` downloads diagram images and uploads to R2 at `ekat/diagrams/<uuid>.<ext>`
- [x] Semantic `updateOrCreate` keys (name+parent_id / name+category_id / name+tool_id)
- [x] `ekat_id` UNIQUE constraint dropped — re-crawls don't hit constraint violations
- [x] Concurrent crawl guard (prevents two crawls interleaving writes)
- [x] NDJSON streaming import — categories/tools appear in UI while crawl is still running
- [x] Filament Browse page (`/admin/festool-ekat-browse`) — category tree + tool cards + diagram viewer + parts table
- [x] Filament Manage page (`/admin/festool-ekat`) — crawl trigger + stop + status + stats
- [x] `dwImageViewerAddHotspot` monkey-patch — captures callout circle positions during diagram navigation
- [x] `extractHotspots(frame, page)` — reads span positions from `_hotspots_click` container as percentages
- [x] `extractPartsAndHotspots(frame, page)` — merges parts + hotspots by position number
- [x] `hotspot_x` / `hotspot_y` stored in `ekat_diagram_parts` (migration `2026_08_10_000001_add_hotspot_columns...`)
- [x] Browse UI — green hotspot circles (`#23aa08`) rendered at `left:X%;top:Y%` over diagram image
- [x] Browse UI — hover sync between diagram hotspots and parts table rows (Alpine `hoveredPart`)
- [x] Browse UI — clicking a hotspot smooth-scrolls to the matching part row
- [x] Browse UI — loading spinner on `wire:loading` during category/tool/diagram switches
- [x] Browse UI — clicking category label text now also expands/collapses tree (not only chevron)

---

## What's Left To Do

- [ ] **Full crawl with parts + images + hotspots** — run without "Skip image downloads" toggle. Will take several hours. Run overnight via queue worker. DB was truncated (all 5 ekat tables) before the last test run; a full fresh crawl is still needed.
- [ ] **Verify diagram image extraction** — first full crawl will confirm whether `extractDiagramImageUrl` finds real images from DocWare, or whether the images require session auth (may need to pass cookies with the HTTP request)
- [ ] **Tool thumbnail images** — `thumbnail_r2_key` field exists but no source yet. Could fetch from Festool public site by model number (e.g. `festool.com/products/<model>`).
- [ ] **Search by part number** — search across all `ekat_diagram_parts.part_number` and link to tool/diagram
- [ ] **Link parts to Cydekick products / purchase orders**
- [ ] **Scheduled re-crawl** (weekly?) to pick up new Festool products

---

## Known Bugs / Gotchas

### DocWare ID instability — BOTH types are session-scoped

**CRITICAL**: Both DOM element IDs (`#EQA65216727`) AND the catalog IDs embedded in `onmouseup` handler strings are SESSION-SCOPED. They change every browser launch.

**Fix**: emit root categories using `cat.ekatId` from the INITIAL session scan (before any navigation), not `freshCat.ekatId` from the post-navigation rescan. Semantic DB keys (name+parent) are used for updateOrCreate so IDs don't matter for deduplication.

### GECKO DOSH / SYS-PH Power Hub: goBack lands at 2-row sub-view
Some W.O. items cause `goBack` to land at a 2-row sub-view instead of the full W.O. listing. The `reNav` callback handles this by going to root and re-clicking the category.

### 8 vs 9 rows in Workplace organisation
After navigating into/out of the first item, W.O. sometimes shows one more row than on initial read. Label-match (not index) handles this gracefully.

### "attempted too many times" errors in console
These are from OLD stale jobs in `failed_jobs` that retry after a DB truncation. Clear with:
```php
DB::table('failed_jobs')->where('payload', 'like', '%EkatCrawlerJob%')->delete();
```

### skipImages toggle also skips parts
`skipImages = true` → passes `--no-parts` to the scraper → skips ALL part/diagram clicking, not just image downloads. "Skip image downloads" in the UI effectively means "structure only" (categories + tools, no diagrams, no parts, no images).

---

## Debug Scripts (in `scripts/`)

| Script | What it tests |
|---|---|
| `ekat-debug-breadcrumb.mjs` | Navigates to CM 150, dumps breadcrumb elements at each level, tests clicking breadcrumbs |
| `ekat-debug-nav.mjs` | Tests left-panel back button navigation at each depth |
| `ekat-debug-goback.mjs` | Traces goBack behavior at Chain mortiser and CM 150 levels |
| `ekat-debug-cm150.mjs` | Dumps all interactive elements at CM 150 level |

---

## Session History / Key Discoveries

1. **DocWare is iframe-based**: all work in content frame (`/ekat\/app/`)
2. **Tree items need mouseup dispatch**: `.click()` doesn't work for left-panel items
3. **Center buttons need full click**: only `frame.locator('#id').click()` fires the onclick
4. **Back navigation is CENTER BREADCRUMB**: left-panel back icon at x=50 is decorative/non-functional
5. **Breadcrumbs need `.click()` too**: `dispatchEvent(mouseup)` is silently ignored for onclick elements
6. **`T - Nr.` has spaces**: original regex `/^T-Nr\.?$/i` never matched; correct is `/^T\s*-\s*Nr\.?$/i`
7. **Label preference**: `getCenterRows` must prefer alphabetic text over numeric T-Nr. codes
8. **domIds go stale**: fixed with `goToRoot()` + re-read before each root category
9. **getCenterRows upper y-bound bug**: `r.top > 870` cut off scrolled items — removed (W.O. 7→29 rows)
10. **T-Nr. = category parent, not tool**: items whose child panel shows `T - Nr.` are categories containing tools, not tools themselves — fixed by `processToolChildren` (items like "Ear protection", "Accessories" were wrongly stored as EkatTool)
11. **Semantic updateOrCreate keys**: switched from session-scoped ekat_id to name+parent for dedup
12. **Diagram image extraction**: `extractDiagramImageUrl()` finds the assembly diagram image by finding the largest img in the content area after navigating into the parts view
13. **Hotspot extraction requires monkey-patch at session start**: `dwImageViewerAddHotspot` is called by DocWare during diagram navigation — it fires BEFORE you can intercept it from within `extractHotspots`. The only working approach is a `setInterval` polling patch installed immediately after the frame is found, before any navigation.
14. **`_hotspots_click` coordinate system is already display-scaled**: The `args[1..4]` values from the patched calls are in an unknown internal/tiled coordinate space — do NOT use them. Instead, read the span pixel positions from the `_hotspots_click` DOM container (DocWare has already done the scaling) and convert to percentages of container width/height.
15. **CSS animation + translate bug**: Putting `transform: translate(-50%,-50%)` on the same element that has a `@keyframes` rotation animation causes the translate to be wiped out each frame (animation overwrites the whole transform). Fix: use a wrapper div for centering and animate only the inner element.
16. **wire:loading + display:flex**: Livewire 3's `wire:loading` removes `display:none` to show but does NOT set `display:flex` — the element falls back to block. Do not rely on `display:flex` for centering inside a `wire:loading` element; use absolute centering or a wrapper instead.
17. **Category label click gap**: `wire:click` fires the Livewire server action but not the Alpine `toggle()`. Must add `x-on:click="toggle(id)"` alongside `wire:click` on the label span for nodes that have children.

---

## Hotspot / Callout Circle Extraction

### What hotspots are

DocWare renders "balloon" callout numbers over the exploded assembly diagram image — small circles with a position number that correspond to rows in the parts list below. These coordinates need to be stored in `ekat_diagram_parts.hotspot_x` / `hotspot_y` so the Browse UI can overlay clickable circles on the diagram image.

### The winning approach: `dwImageViewerAddHotspot` monkey-patch

DocWare calls a global JS function `dwImageViewerAddHotspot(elementId, x1, y1, x2, y2, ..., position, ..., tooltipHtml, ...)` for every hotspot when a diagram is loaded via AJAX. The position number is at `args[13]`; fallback: `args[16]` contains tooltip HTML with "Pos: N".

**Critical**: these calls fire during DocWare's AJAX navigation response processing — NOT during any mouse action in `extractHotspots`. By the time you're in `extractHotspots`, the calls have already fired. The only way to capture them is to install the patch **before any navigation happens**, immediately after the frame is found.

**Install the patch (right after frame is located):**
```javascript
await frame.evaluate(() => {
    window.__dwHotspotLog = [];
    const tryPatch = () => {
        if (typeof dwImageViewerAddHotspot === 'function' && !dwImageViewerAddHotspot._patched) {
            const orig = dwImageViewerAddHotspot;
            window.dwImageViewerAddHotspot = function (...args) {
                window.__dwHotspotLog.push([...args]);
                return orig.apply(this, args);
            };
            window.dwImageViewerAddHotspot._patched = true;
            clearInterval(window.__dwPatchInterval);
        }
    };
    window.__dwPatchInterval = setInterval(tryPatch, 100);
    tryPatch();
});
```

### Coordinate extraction — `_hotspots_click` container

`args[1..4]` (x1,y1,x2,y2) are in an UNKNOWN internal/tiled space — they give nonsensical values like y=152% when treated as image-relative percentages. **Do not use them.**

Instead, DocWare lazily creates a `<div id="CONTAINERID_hotspots_click">` with `<span>` elements positioned over each callout circle when the user first hovers the diagram. These spans are already in display-pixel coordinates. Convert span centers to percentages of the container's width/height.

**Steps in `extractHotspots(frame, page)`:**
1. Hover the diagram image (`page.mouse.move` to center of `img[src*="ba_image_EXPL"]` bounding box)
2. Wait 800ms for `_hotspots_click` to be created
3. Read the container ID: `document.querySelector('[id$="_hotspots_click"]')?.id?.replace('_hotspots_click', '')`
4. If `cid` is null → return `[]` (no hotspots for this diagram)
5. Read `window.__dwHotspotLog` filtered by `a[0] === cid` (first arg is the container element ID)
6. Read span positions from `_hotspots_click`: `container.querySelectorAll('span[id][style*="cursor"]')`
7. Pair log entries with spans BY INDEX (same order as registration)
8. Compute `cx = (span.left + span.width/2) / containerWidth * 100`, same for cy

**Deduplication**: track seen position numbers with a `Set` — skip if already seen.

### Full `extractHotspots` implementation
See `scripts/ekat-scrape.mjs` — function `extractHotspots(frame, page)` and `extractPartsAndHotspots(frame, page)`.

`extractPartsAndHotspots` runs `extractParts()` first (no mouse needed), then `extractHotspots()`, then merges by position number into the parts array before emitting.

### Failed approaches (do not retry)

| Approach | Why it failed |
|---|---|
| Image `<map>` elements | DocWare doesn't use `<map>` — no such elements in the DOM |
| Reading absolutely-positioned elements at load time | Hotspot spans only exist after mouse hover; not present at diagram load |
| MutationObserver on hover | DocWare applies CSS `:hover` pseudo-classes — no DOM attribute mutations occur |
| Network interception for `setHoverClasses` | Sent during navigation clicks, not during hover; 0 responses captured in extractHotspots |
| AJAX capture of `dwImageViewerAddHotspot` during hover phase | Function called during navigation, not hover — 0 captures |
| Using `args[1..4]` as image-relative coords | Unknown/tiled coordinate space; gives values like y=152% |
| Clicking a span to capture nav response | Triggers DocWare's **print dialog** — wrong behavior |
| `frame.mouse.move()` | `mouse` is on `page`, not `frame` — use `page.mouse.move()` |
| `null` containerId bug | If cid is null, filter `a[0] === null` matches nothing — but original code returned ALL log entries unfiltered, mixing calls from different diagrams. Fixed with early `return []` when cid is null. |

### DB columns

Migration: `plugins/webkul/festool-ekat/database/migrations/2026_08_10_000001_add_hotspot_columns_to_ekat_diagram_parts.php`

```php
$table->float('hotspot_x')->nullable()->after('is_available'); // % from left (0–100)
$table->float('hotspot_y')->nullable()->after('hotspot_x');    // % from top  (0–100)
```

In `EkatCrawlerJob::safeUpsert` (parts):
```php
'hotspot_x' => isset($d['hotspot_x']) ? (float) $d['hotspot_x'] : null,
'hotspot_y' => isset($d['hotspot_y']) ? (float) $d['hotspot_y'] : null,
```

---

## Browse UI — `browse-ekat.blade.php`

### Architecture

Single Filament page at `/admin/festool-ekat-browse`. Left sidebar: Alpine `ekatTree()` component manages expand/collapse state locally; Livewire manages active selection server-side. Right main: tool grid → diagram section → parts table, all re-rendered by Livewire on each selection change.

### Hotspot overlay

PHP filters `partsForDiagram` to only rows with non-null `hotspot_x`/`hotspot_y` and passes them to Alpine as `hotspots`:
```blade
@php $hotspotData = array_values(array_filter($this->partsForDiagram, fn($p) => isset($p['hotspot_x'], $p['hotspot_y']) && $p['hotspot_x'] !== null)); @endphp
x-data="{ hoveredPart: null, hotspots: {{ Js::from($hotspotData) }} }"
```

Hotspots rendered via `<template x-for="spot in hotspots">`:
```html
<div class="ek-hotspot"
    :class="{ highlighted: hoveredPart === spot.id, unavailable: !spot.is_available }"
    :style="`left:${spot.hotspot_x}%;top:${spot.hotspot_y}%`"
    x-on:mouseenter="hoveredPart = spot.id"
    x-on:mouseleave="hoveredPart = null"
    x-on:click="hoveredPart = spot.id; $nextTick(() => { const r = document.getElementById('part-row-' + spot.id); if (r) r.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); })"
    x-text="spot.position"
></div>
```

The outer image container must be `position:relative; display:inline-block` for percentage positioning to work against the image dimensions (not the full canvas width).

### Hotspot styling

```css
.ek-hotspot {
    position: absolute; width: 22px; height: 22px; border-radius: 50%;
    background: #23aa08;   /* green — previously #4f46e5 blue */
    border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.25);
    transform: translate(-50%, -50%); cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    font-size: .6rem; font-weight: 700; color: #fff;
    transition: transform .15s, box-shadow .15s; z-index: 2;
}
.ek-hotspot:hover, .ek-hotspot.highlighted {
    transform: translate(-50%, -50%) scale(1.35);
    box-shadow: 0 0 0 4px rgba(35,170,8,.3);
    z-index: 3;
}
.ek-hotspot.unavailable { background: #9ca3af; }
```

### Hover sync (diagram ↔ parts table)

`hoveredPart` is an Alpine property holding the `id` of the currently hovered part. Both the hotspot divs and the `<tr>` rows bind `:class="{ highlighted: hoveredPart === partId }"` and `x-on:mouseenter/mouseleave`. A `window` event listener `x-on:ekat-hover-part.window` also wires external callers.

### Loading spinner

Placed as the first child of `.ek-main` (which has `position: relative`):
```html
<div class="ek-loading-overlay" wire:loading wire:target="selectCategory,selectTool,selectDiagram">
    <div class="ek-spinner-wrap"><div class="ek-spinner"></div></div>
</div>
```

```css
.ek-loading-overlay { position: absolute; inset: 0; z-index: 20; background: rgba(255,255,255,.72); border-radius: 10px; backdrop-filter: blur(2px); pointer-events: none; }
.ek-spinner-wrap { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
@keyframes ek-spin { to { transform: rotate(360deg); } }
.ek-spinner { width: 36px; height: 36px; border: 3px solid #e5e7eb; border-top-color: #4f46e5; border-radius: 50%; animation: ek-spin .6s linear infinite; }
```

**Two gotchas solved here:**
1. `wire:loading` → Livewire 3 removes `display:none` but doesn't set `display:flex`. Centering via `justify-content/align-items` on the overlay won't work. Use a wrapper with `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%)` instead.
2. CSS `@keyframes` with `transform:rotate()` overwrites the `transform:translate(-50%,-50%)` on the same element each frame. The spinner slides and doesn't stay centred. Fix: separate wrapper div handles translate; spinner div handles rotation only.

### Category tree — label click also expands

The label `<span>` had `wire:click="selectCategory(id)"` but no Alpine toggle. For nodes with children, `x-on:click="toggle(id)"` must be added alongside:
```blade
<span
    class="ek-tree-label"
    wire:click="selectCategory({{ $node['id'] }})"
    @if ($node['hasChildren']) x-on:click="toggle({{ $node['id'] }})" @endif
>{{ $node['name'] }}</span>
```

`wire:click` and `x-on:click` fire independently on the same element — both work.

---

## `safeUpsert` Pattern in `EkatCrawlerJob`

Handles race conditions when NDJSON lines are processed rapidly (duplicate key violations on concurrent upserts):

```php
private function safeUpsert(string $model, array $where, array $values): mixed
{
    try {
        return $model::updateOrCreate($where, $values);
    } catch (UniqueConstraintViolationException $e) {
        $record = $model::where($where)->firstOrFail();
        $record->update($values);
        return $record->fresh();
    }
}
```

---

## Queue Config for Long Crawl Jobs

`config/queue.php` — `retry_after` set to **10800 seconds (3 hours)** to prevent zombie re-queuing of multi-hour crawl jobs:
```php
'database' => [
    ...
    'retry_after' => 10800,
],
```

Without this, Laravel's default 90s `retry_after` re-queues the job as "failed" while it's still running, creating duplicate crawls.

---

## Production Deployment — First-Time Setup

### Standard deploy sequence
```bash
git pull && composer install --no-dev --optimize-autoloader
npm ci --production=false && npm run build
php artisan festool-ekat:install   # ← registers plugin in plugins table + runs migrations
php artisan view:clear && php artisan optimize:clear && php artisan optimize
sudo supervisorctl restart cydekick-worker:* cydekick-scheduler
```

**Do not use `php artisan migrate --force` alone** — the plugin won't appear in the nav until it's registered in the `plugins` table. The `festool-ekat:install` command does both migrations AND registration.

### Plugin must be registered in AdminPanelProvider

`FestoolEkatPlugin::make()` must be in `app/Providers/Filament/AdminPanelProvider.php`:
```php
use Webkul\FestoolEkat\FestoolEkatPlugin;
// ...
->plugins([
    PluginManager::make(),
    FestoolEkatPlugin::make(),   // ← required
    FilamentShieldPlugin::make(),
    ...
])
```

Without this, even a correctly installed plugin won't show in the sidebar. **This was missing on first deploy** — the plugin appeared to install but had no nav item.

### How `isPluginInstalled()` works

`FestoolEkatPlugin::register()` calls `Package::isPluginInstalled('festool-ekat')` and returns early if false. This checks the `plugins` DB table for a row with `name = 'festool-ekat'` and `is_installed = true`. `festool-ekat:install` writes this row via `Package::updateOrCreate()`. If the row is missing, no nav item appears and no pages register — even if migrations ran.

### Node.js version requirement

Playwright 1.62.1 requires **Node.js >= 20**. Ubuntu 24.04 ships Node 18 by default. Upgrade:
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install nodejs -y
node --version   # must show v20.x.x
```

### Playwright browser installation

```bash
npx playwright install chromium
npx playwright install-deps chromium
```

**Critical**: Install the browsers to a world-readable location, NOT the default `/root/.cache/`. The queue worker runs as `www-data` which cannot read `/root/`. Install to `/opt/playwright-browsers`:

```bash
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers npx playwright install chromium
chmod -R 755 /opt/playwright-browsers
# install-deps installs system packages (libatk, xvfb etc.) — only needs to run once
npx playwright install-deps chromium
```

If you accidentally install to `/root/.cache/ms-playwright/` first, just re-run the install with `PLAYWRIGHT_BROWSERS_PATH` set.

### `EkatCrawlerJob` — `PLAYWRIGHT_BROWSERS_PATH` env var

The Symfony `Process` that spawns `node ekat-scrape.mjs` must pass `PLAYWRIGHT_BROWSERS_PATH` explicitly, otherwise Playwright falls back to `~/.cache/ms-playwright/` using `www-data`'s home (`/var/www`) and finds nothing:

```php
$env = array_filter(getenv()) + [
    'HOME'                     => getenv('HOME') ?: '/var/www',
    'PLAYWRIGHT_BROWSERS_PATH' => '/opt/playwright-browsers',
];
$process = new Process($cmd, base_path(), $env, null, 7200);
```

This is already in the job. If the browsers are ever re-installed to a different path, update this constant.

### Migration gotcha — stored generated column + FK

The migration `2026_08_25_000001_add_unique_constraints_to_ekat_tables` originally tried to add a stored generated column (`COALESCE(parent_id, 0)`) on `ekat_categories`. MySQL blocks this in two ways:
1. Cannot add a stored generated column to a table that has FK constraints
2. Cannot re-add a FK on a column that is the base of a stored generated column

**Final fix**: dropped the stored-column approach entirely. `ekat_tools` and `ekat_diagrams` get normal unique indexes; `ekat_categories` deduplication stays application-level via `updateOrCreate(['name', 'parent_id'], ...)`. The migration also cleans up any partial state from earlier failed attempts using `Schema::hasColumn()` and `information_schema` FK checks.

### Playwright system dependencies

`npx playwright install-deps chromium` installs Linux system packages needed by headless Chrome (libatk, libatspi, libasound2, xvfb, fonts etc.). This only needs to be run once per server. If the browser launches but immediately crashes with `{ log: [], name: 'Error' }`, the system deps are missing.

### Diagnosing browser launch failures

Symptom: crawl fails in `<1 second` with `{ log: [], name: 'Error' }` at line 792 (chromium.launch).

Checklist:
1. **Verify binary exists**: `find /opt/playwright-browsers -name "chrome" -type f`
2. **Verify binary runs**: `/opt/playwright-browsers/chromium-*/chrome-linux64/chrome --headless --no-sandbox --version`
3. **Verify www-data can read it**: `sudo -u www-data ls /opt/playwright-browsers/`
4. **Test as www-data**: `sudo -u www-data PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers node /var/www/html/Cydekick/scripts/ekat-scrape.mjs --limit-cats 1 --no-parts 2>&1 | head -20`
5. **Check system deps**: `npx playwright install-deps chromium`

---

## Current State (as of 2026-08-25)

- Plugin deployed to production at `cydekick.co.uk` and working
- All 5 ekat tables are empty — full crawl has not yet been run on production
- Playwright browsers installed at `/opt/playwright-browsers` on production server
- Queue worker (www-data via Supervisor) can now successfully launch the scraper
- Full crawl started — will take several hours to complete
- To trigger a new crawl: Admin → Connectors → Festool eKat → Start Full Crawl
