diff --git a/PLAN_OPTIMIZATION.md b/PLAN_OPTIMIZATION.md new file mode 100644 index 0000000..79e4718 --- /dev/null +++ b/PLAN_OPTIMIZATION.md @@ -0,0 +1,185 @@ +# План за оптимизация и Phase 4 — Реално web research + +## 1. Текущо състояние — одит + +### ✅ Работи добре +- Core SPA (React + Vite + TypeScript) — чиста архитектура +- Фаза 0-3 приложени: демо изчистено, modelSearch, engine config, UI подобрения +- Express backend с REST API (7 endpoint-а) +- Zod валидация на всички граници +- localStorage с авто-чистене и валидация +- 27 теста, TypeScript — чиста компилация +- i18n (BG/EN) в цялото UI +- URL query params за споделяне +- Vite preview работи + +### ❌ Проблеми за оптимизация + +| Проблем | Сериозност | Описание | +|---------|-----------|----------| +| **Bundle замърсяване** | Високо | `ssh2`, `express`, `pg`, `cors` са в `dependencies` — Vite ги включва в клиентския bundle (338KB). Те са само за server.ts. | +| **ExternalResearchPanel е мъртъв код** | Средно | Хардкоднати 4 демо записа, setTimeout имитация. Табът скрит зад env флаг. | +| **Няма реален web research** | Високо | GSMArena няма public API — трябва scraping. Няма endpoint `/api/v1/research`. | +| **Базата е само localStorage** | Средно | Няма синхрон между устройства. Всеки браузър си има собствена база. | +| **phoneDatabase.ts нараства** | Ниско | 23KB хардкод- Same UI components can be optimized, no code splitting | +| **Няма форма за липсващ модел** | Средно | Потребителят не може да въведе specs ръчно когато GSMArena няма данни | + +--- + +## 2. План за действие + +### 🌊 Фаза 0.5 — Code Hygiene (преди нови features) + +| Задача | Файлове | Описание | +|--------|---------|----------| +| Премахни `ssh2`, `express`, `pg`, `cors` от `dependencies` | `package.json` | Server-only пакетите да са в `optionalDependencies` или `server/package.json` | +| Separate server/ от src/ | `server.ts` → `server/index.ts` | Раздели backend от frontend за clean separation | +| Dead code elimination | `ExternalResearchPanel.tsx` | Премахни SIMULATED_RESEARCH_DATABASE и setTimeout логиката | + +### 🌐 Фаза 4 — Real Web Research (GSMArena) + +**Архитектура:** + +``` +┌─────────────┐ POST /api/v1/research ┌──────────────────┐ +│ Frontend │ ───────────────────────────? │ Express Server │ +│ (Vite SPA) │ │ (server/) │ +│ │ ←─── PhoneModel[] ─────────── │ │ +└─────────────┘ └───────┬──────────┘ + │ + │ axios + cheerio + ▼ + ┌──────────────────┐ + │ GSMArena HTML │ + │ scraping │ + └──────────────────┘ +``` + +#### 4.1 Server-side research endpoint + +**Нов файл:** `server/routes/research.ts` + +```typescript +// POST /api/v1/research +// Body: { query: "Galaxy A06" } +// Returns: { found: boolean, model?: PhoneModel, source: "gsmarena"|"none", confidence: "RESEARCHED" } + +- Търси модела в GSMArena: + 1. Construct URL: gsmarena.com/results.php3?sQuickSearch=yes&sName=Galaxy+A06 + 2. Парсва HTML с cheerio — взима първия резултат от search + 3. Отваря страницата на модела: gsmarena.com/{model}.php + 4. Парсва спецификациите (размери, екран, камера, батерия) + 5. Map-ва към PhoneModel тип + 6. Връща резултата с confidence: "RESEARCHED" +``` + +#### 4.2 GSMArena HTML Parser + +**Нов файл:** `server/utils/gsmarenaParser.ts` + +Парсва: +- **Dimensions**: `.specs-list` → `168.0 x 78.0 x 8.8 mm` → height/width/thickness +- **Display**: `6.7"`, `PLS LCD`, `720 x 1600 px` → diagonal, curvature +- **Camera**: `3 MP` → lensCount, `rectangular island` +- **Features**: `USB-C`, `3.5mm jack`, `Fingerprint (side-mounted)` +- **Notch type** derived from resolution, aspect ratio, and display description +- **Model codes/aliases**: `SM-A057F, SM-A057M` from "Also known as" + +#### 4.3 Client-side Research UI + +**Rewrite `ExternalResearchPanel.tsx`:** + +- Премахни симулираните данни +- Бутон "Search GSMArena" → извиква POST /api/v1/research +- Резултати с badge `RESEARCHED` (нов confidenceLevel) +- Бутон "Add to Catalog" → само ако id не съществува +- Поле за ръчно редактиране на specs преди add +- Cache в localStorage с 24h expiry + +#### 4.4 Нов type — ResearchResult + +```typescript +// В types.ts +interface ResearchResult { + id: string; + model?: PhoneModel; + source: 'gsmarena' | 'none'; + confidenceLevel: 'RESEARCHED'; + searchedAt: string; + rawSpecs: Record; // оригиналните спецификации от HTML-а +} +``` + +#### 4.5 Auto-Discovery Flow + +1. Consumator търсим модел който не е в локальната база +2. UI: "Този модел не е в каталога.Зонайте от GSMArena?" +3. Ако ползоателят approves → извикай `/api/v1/research` +4. Резултат: покажи specs, "RESEARCHED" badge +5. "Promote to Catalog" → добави в PhoneModel[] → сохрани в localStorage +6. При повторно търсене: провери в кеша преди да питаш сървъра + +### 📦 Фаза 4.5 — Missing Model Form + +**Нов/подобрен компонент:** `components/MissingModelForm.tsx` + +- Показва се когато търсенето не намира нищо нито локално нито онлайн +- Форма с всички PhoneModel полета (без id — auto-generate) +- Copy от AdminPairManagerModal "Register New Phone Model" секцията +- Валидация със Zod +- Съхранява се в localStorage + +### �⚡ Оптимизации + +| Оптимизация | Ефект | +|------------|--------| +| Prемени `ssh2`, `express`, `pg`, `cors` от dependencies | Намаля bundle-а с ~150KB | +| Кеш на research резултати (24h) | Избягва излишни re-scrape-ове | +| Debounce на търсенето (300ms) | По-малко заявки докато потребителя пише | +| Code splitting за модалите | Намалява initial bundle | +| Лоадаble imports за ExternalResearchPanel | Зарежда се само когато се кликне на Research таба | + +--- + +## 3. Файлова структура след промените + +``` +CaseScreenChecker/ +├── server/ +│ ├── index.ts ← преместен от server.ts +│ ├── routes/ +│ │ └── research.ts ← NEW: /api/v1/research +│ └── utils/ +│ └── gsarenaParser.ts ← NEW: HTML scraping +├── src/ +│ ├── components/ +│ │ ├── ExternalResearchPanel.tsx ← REWRITE: real research +│ │ └── MissingModelForm.tsx ← NEW: manual specs form +│ ├── utils/ +│ │ └── researchCache.ts ← NEW: localStorage cache with TTL +│ ├── types.ts ← ADD: ResearchResult +│ └── ... +├── package.json ← CLEAN: server deps separated +└── vite.config.ts +``` + +## 4. Тестове + +| Компонент | Тестове | +|-----------|---------| +| `gsmarenaParser.ts` | Unit: парсване на HTML → PhoneModel, липсващи полета, грешки | +| `researchCache.ts` | Unit: set/get/expiry/cleanup | +| `research.ts` route | Integration: POST /api/v1/research с mock axios | +| `MissingModelForm.tsx` | Component: submit валидни/невалидни данни | + +## 5. Приоритет (execution order) + +1. **Code hygiene** — премахни server deps от frontend bundle, отдели server/ +2. **gsmarenaParser** — ядрото на реалното търсене +3. **researchCache** — клиентски кеш +4. **server/research.ts** — API endpoint +5. **ExternalResearchPanel** — rewrite с реална функционалност +6. **MissingModelForm** — fallback форма +7. **Tests** — за всички нови модули +8. **Deploy** — обнови Vercel/server config +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6d4d909..47ef5a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@supabase/supabase-js": "^2.112.3", + "axios": "^1.19.0", + "cheerio": "^1.2.0", "clsx": "^2.1.1", "cors": "^2.8.6", "dotenv": "^17.4.2", @@ -2198,6 +2200,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "dev": true, @@ -2230,6 +2244,24 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "dev": true, @@ -2291,6 +2323,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.9", "dev": true, @@ -2425,6 +2463,48 @@ "node": ">= 16" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/clsx": { "version": "2.1.1", "license": "MIT", @@ -2432,6 +2512,18 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "license": "MIT", @@ -2511,6 +2603,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "dev": true, @@ -2544,6 +2664,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -2559,6 +2688,61 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2599,6 +2783,31 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "dev": true, @@ -2611,6 +2820,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -2640,6 +2861,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "dev": true, @@ -3025,6 +3261,63 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -3174,6 +3467,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "license": "MIT", @@ -3197,6 +3505,37 @@ "hermes-estree": "0.25.1" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "license": "MIT", @@ -3215,6 +3554,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -3817,6 +4169,18 @@ "node": ">=18" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -3895,6 +4259,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -4129,6 +4542,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -4655,6 +5077,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "8.3.0", "dev": true, @@ -4876,6 +5307,40 @@ } } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/package.json b/package.json index 4ab013c..71325a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "case-screen-checker", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 3000", @@ -14,20 +14,24 @@ "test:watch": "vitest" }, "dependencies": { - "@supabase/supabase-js": "^2.112.3", + "axios": "^1.7.9", + "cheerio": "^1.0.0", "clsx": "^2.1.1", - "cors": "^2.8.6", - "dotenv": "^17.4.2", - "express": "^5.2.1", "lucide-react": "^1.16.0", "motion": "^12.4.7", - "pg": "^8.23.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "ssh2": "^1.17.0", "tailwind-merge": "^3.0.2", "zod": "^3.24.1" }, + "optionalDependencies": { + "@supabase/supabase-js": "^2.112.3", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "pg": "^8.23.0", + "ssh2": "^1.17.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.0.9", @@ -46,4 +50,4 @@ "vite": "^6.2.0", "vitest": "^3.0.0" } -} +} \ No newline at end of file diff --git a/server.ts b/server.ts index de96fbc..b5a1634 100644 --- a/server.ts +++ b/server.ts @@ -6,6 +6,7 @@ import { INITIAL_PHONE_MODELS, INITIAL_COMPATIBILITY_PAIRS } from './src/data/ph import { PhoneModel, CompatibilityPair } from './src/types.js'; import { getCompatibilityResultsForModel } from './src/utils/compatibilityEngine.js'; import { phoneModelSchema, compatibilityPairSchema, categoryEnum } from './src/validation/schemas.js'; +import researchRouter from './server/routes/research.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -166,6 +167,9 @@ app.post('/api/v1/compatibility/pairs', (req: Request, res: Response) => { res.status(201).json({ message: 'Compatibility pair saved successfully', data: newPair }); }); +// 8. POST /api/v1/research - Web research via GSMArena +app.use('/api/v1/research', researchRouter); + // ========================================== // STATIC PRODUCTION BUILD (Vite SPA) // ========================================== diff --git a/server/routes/research.ts b/server/routes/research.ts new file mode 100644 index 0000000..7b20e7f --- /dev/null +++ b/server/routes/research.ts @@ -0,0 +1,144 @@ +/** + * research.ts — Server-side research API endpoint. + * + * POST /api/v1/research + * Body: { query: string } + * Returns: { found: boolean, model?: PhoneModel, source: string, rawSpecs?: Record } + * + * Scrapes GSMArena for phone specifications when they're not in the local catalog. + * Uses axios for HTTP and cheerio for HTML parsing. + */ + +import { Router, Request, Response } from 'express'; +import axios from 'axios'; +import { parseSearchResults, parseSpecsPage } from '../utils/gsmarenaParser.js'; +import type { PhoneModel } from '../../src/types.js'; +import { phoneModelSchema } from '../../src/validation/schemas.js'; + +const router = Router(); + +const GSMARENA_BASE = 'https://www.gsmarena.com'; +const RESEARCH_TIMEOUT_MS = 15_000; +const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; + +interface ResearchBody { + query: string; +} + +interface ResearchSuccessResponse { + found: true; + model: PhoneModel; + source: 'gsmarena'; + sourceUrl: string; + rawSpecs: Record; +} + +interface ResearchNotFoundResponse { + found: false; + source: 'none'; + error: string; +} + +type ResearchResponse = ResearchSuccessResponse | ResearchNotFoundResponse; + +/** + * POST /api/v1/research + * + * Searches GSMArena for the given query and returns parsed phone specs. + * First tries to find the exact model via search, then parses the specs page. + */ +router.post('/', async (req: Request, res: Response) => { + const { query } = req.body as ResearchBody; + + if (!query || typeof query !== 'string' || query.trim().length === 0) { + return res.status(400).json({ + found: false, + source: 'none', + error: 'Query parameter "query" is required and must be a non-empty string.', + }); + } + + const searchQuery = query.trim(); + + try { + // Step 1: Search GSMArena + const searchUrl = `${GSMARENA_BASE}/results.php3?sQuickSearch=yes&sName=${encodeURIComponent(searchQuery)}`; + const searchRes = await axios.get(searchUrl, { + headers: { 'User-Agent': USER_AGENT }, + timeout: RESEARCH_TIMEOUT_MS, + }); + + const searchResults = parseSearchResults(searchRes.data); + + if (searchResults.length === 0) { + return res.json({ + found: false, + source: 'none', + error: `No results found on GSMArena for "${searchQuery}".`, + } as ResearchNotFoundResponse); + } + + // Step 2: Open the first result's specs page + const firstResult = searchResults[0]; + const specsUrl = `${GSMARENA_BASE}/${firstResult.path}.php`; + const specsRes = await axios.get(specsUrl, { + headers: { 'User-Agent': USER_AGENT }, + timeout: RESEARCH_TIMEOUT_MS, + }); + + // Step 3: Parse specifications + const parsed = parseSpecsPage(specsRes.data, specsUrl); + + if (!parsed.success || !parsed.model) { + return res.json({ + found: false, + source: 'none', + error: parsed.error || `Failed to parse specifications for "${firstResult.title}".`, + } as ResearchNotFoundResponse); + } + + // Step 4: Validate the parsed model with Zod schema + const validation = phoneModelSchema.safeParse(parsed.model); + if (!validation.success) { + // Still return what we parsed even if validation fails — let the client decide + return res.json({ + found: true, + model: parsed.model, + source: 'gsmarena', + sourceUrl: specsUrl, + rawSpecs: parsed.rawSpecs || {}, + _validation: validation.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })), + } as ResearchSuccessResponse & { _validation: any }); + } + + return res.json({ + found: true, + model: validation.data, + source: 'gsmarena', + sourceUrl: specsUrl, + rawSpecs: parsed.rawSpecs || {}, + } as ResearchSuccessResponse); + + } catch (err: any) { + if (axios.isAxiosError(err)) { + const status = err.response?.status || 502; + const msg = err.code === 'ECONNABORTED' + ? `GSMArena request timed out after ${RESEARCH_TIMEOUT_MS}ms.` + : `GSMArena request failed: ${err.message}`; + return res.status(status).json({ + found: false, + source: 'none', + error: msg, + } as ResearchNotFoundResponse); + } + + console.error('[Research] Unexpected error:', err); + return res.status(500).json({ + found: false, + source: 'none', + error: 'Internal server error processing research request.', + } as ResearchNotFoundResponse); + } +}); + +export default router; \ No newline at end of file diff --git a/server/utils/gsmarenaParser.test.ts b/server/utils/gsmarenaParser.test.ts new file mode 100644 index 0000000..dcf0af5 --- /dev/null +++ b/server/utils/gsmarenaParser.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for the GSMArena parser — the core of the real web research. + * + * Uses realistic HTML snippets to verify parsing of search results + * and specifications pages. + */ + +import { describe, it, expect } from 'vitest'; +import { parseSearchResults, parseSpecsPage } from './gsmarenaParser'; + +const MOCK_SEARCH_HTML = ` + + + + + +`; + +const MOCK_SPECS_HTML = ` + + +

Samsung Galaxy A06

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Dimensions167.3 x 77.3 x 8.0 mm
Weight189 g
Display6.7" PLS LCD, 720 x 1600 px, 20:9 ratio
ChipsetMediatek Helio G85
Rear camera50 MP, f/1.8 (wide), 2 MP, f/2.4 (macro), 2 MP, f/2.4 (depth)
LoudspeakerYes, with 3.5mm jack
SensorsFingerprint (side-mounted), accelerometer, proximity
USBUSB Type-C 2.0
Launched2024, August
Also known asSM-A065F, SM-A065M
+ + +`; + +describe('parseSearchResults', () => { + it('extracts phone names and paths from GSMArena search HTML', () => { + const results = parseSearchResults(MOCK_SEARCH_HTML); + expect(results.length).toBe(3); + expect(results[0].title).toBe('Samsung Galaxy A06'); + expect(results[0].path).toBe('samsung_galaxy_a06-12345'); + expect(results[1].title).toBe('Samsung Galaxy A05s'); + }); + + it('returns empty array for HTML without search results', () => { + const results = parseSearchResults('No results'); + expect(results.length).toBe(0); + }); +}); + +describe('parseSpecsPage', () => { + it('parses dimensions, screen, camera, and features', () => { + const result = parseSpecsPage(MOCK_SPECS_HTML, 'https://gsmarena.com/samsung_galaxy_a06-12345.php'); + + expect(result.success).toBe(true); + expect(result.model).toBeDefined(); + expect(result.rawSpecs).toBeDefined(); + + const model = result.model!; + expect(model.fullName).toBe('Samsung Galaxy A06'); + expect(model.brand).toBe('Samsung'); + expect(model.dimensions.height).toBe(167.3); + expect(model.dimensions.width).toBe(77.3); + expect(model.dimensions.thickness).toBe(8.0); + expect(model.dimensions.weightG).toBe(189); + expect(model.screen.diagonalIn).toBe(6.7); + expect(model.screen.aspectRatio).toBe('20:9'); + expect(model.features.hasHeadphoneJack).toBe(true); + expect(model.features.fingerprint).toBe('side_power_button'); + expect(model.features.portType).toBe('usb_c'); + expect(model.camera.lensCount).toBe(3); + expect(model.aliases).toContain('SM-A065F'); + expect(model.aliases).toContain('SM-A065M'); + expect(model.releaseYear).toBe(2024); + }); + + it('handles incomplete HTML gracefully', () => { + const result = parseSpecsPage('

Test Phone

', 'http://example.com'); + expect(result.success).toBe(false); + expect(result.error).toContain('Incomplete specs'); + }); + + it('detects missing device name', () => { + const result = parseSpecsPage('No name here', 'http://example.com'); + expect(result.success).toBe(false); + expect(result.error).toContain('Could not find device name'); + }); +}); \ No newline at end of file diff --git a/server/utils/gsmarenaParser.ts b/server/utils/gsmarenaParser.ts new file mode 100644 index 0000000..0c471ed --- /dev/null +++ b/server/utils/gsmarenaParser.ts @@ -0,0 +1,267 @@ +/* gsmarenaParser.ts - Parse GSMArena HTML into PhoneModel objects */ +import * as cheerio from "cheerio"; +import { PhoneModel, ScreenNotchType, CameraIslandShape } from "../../src/types.js"; + +export interface ParsedSearchResult { path: string; title: string; } +export interface GsmarenaParseResult { + success: boolean; model?: PhoneModel; rawSpecs?: Record; error?: string; +} + +export function parseSearchResults(html: string, limit = 5): ParsedSearchResult[] { + const dollar = cheerio.load(html); + const results: ParsedSearchResult[] = []; + dollar(".makers a, .search-results a, .st-text a").each((_i, el) => { + const href = dollar(el).attr("href") || ""; + const title = dollar(el).find("span").text().trim() || dollar(el).text().trim(); + if (href && title && href.endsWith(".php") && !href.includes("results")) { + results.push({ path: href.replace(".php", ""), title }); + } + }); + if (results.length === 0) { + dollar("ul li a").each((_i, el) => { + const href = dollar(el).attr("href") || ""; + const title = dollar(el).text().trim(); + if (href && title && href.endsWith(".php") && !href.includes("results") && !href.includes("#")) { + results.push({ path: href.replace(".php", ""), title }); + } + }); + } + return results.slice(0, limit); +} + +export function parseSpecsPage(html: string, sourceUrl: string): GsmarenaParseResult { + const dollar = cheerio.load(html); + const rawSpecs: Record = {}; + const model: Record = {}; + + const deviceName = dollar( + ".specs-phone-name-title, h1.specs-phone-name, .article-info-name" + ).first().text().trim(); + if (!deviceName) { + return { success: false, error: "Could not find device name on the page." }; + } + + // Parse spec table rows + dollar("table tr").each((_rowIdx, row) => { + const dollarRow = dollar(row); + const labelEl = dollarRow.find(".ttl, .specs-ttl"); + const valueEl = dollarRow.find(".nfo, .specs-nfo"); + if (labelEl.length && valueEl.length) { + const label = labelEl.text().trim().toLowerCase(); + const value = valueEl.text().trim(); + if (label && value) rawSpecs[label] = value; + } + }); + + // Alternative: li > strong label + dollar(".specs-list li").each((_i, el) => { + const dollarEl = dollar(el); + const strong = dollarEl.find("strong").first(); + const label = strong.text().trim().toLowerCase().replace(/[:\s]+/g, "").trim(); + if (label) { + strong.remove(); + const value = dollarEl.text().trim(); + if (value && !rawSpecs[label]) rawSpecs[label] = value; + } + }); + + return _buildModel(rawSpecs, model, deviceName, sourceUrl); +} + +function _buildModel( + rawSpecs: Record, + model: Record, + deviceName: string, + sourceUrl: string +): GsmarenaParseResult { + model.fullName = deviceName; + model.name = deriveShortName(deviceName); + model.brand = deriveBrand(deviceName); + + // Dimensions: "167.3 x 77.3 x 8.0 mm" + const dimRaw = rawSpecs["dimensions"] || rawSpecs["size"] || ""; + const dimMatch = dimRaw.match(/([0-9.]+)\s* x \s*([0-9.]+)\s* x \s*([0-9.]+)\s*mm/i); + if (dimMatch) { + model.dimensions = { + height: parseFloat(dimMatch[1]), + width: parseFloat(dimMatch[2]), + thickness: parseFloat(dimMatch[3]), + }; + } + + // Weight: "189 g" + const weightRaw = rawSpecs["weight"] || ""; + const weightMatch = weightRaw.match(/([0-9]+)/); + if (weightMatch) { + if (!model.dimensions) model.dimensions = {}; + model.dimensions.weightG = parseInt(weightMatch[1], 10); + } + + // Display: '6.7" PLS LCD' + const displaySpec = rawSpecs["display"] || rawSpecs["type"] || ""; + const diagMatch = displaySpec.match(/([0-9.]+)"/); + if (diagMatch) { + model.screen = model.screen || {}; + model.screen.diagonalIn = parseFloat(diagMatch[1]); + model.screen.aspectRatio = deriveAspectRatio(displaySpec); + model.screen.curvature = displaySpec.toLowerCase().includes("curved") + ? "2.5d_curved_edge" + : "flat"; + model.screen.hasCurvedEdges = model.screen.curvature !== "flat"; + } + + // Notch type + const notchDerived = deriveNotchType(displaySpec, deviceName); + if (notchDerived && model.screen) { + model.screen.notchType = notchDerived; + } + + // Camera + const cameraDesc = rawSpecs["rear camera"] || rawSpecs["camera"] || ""; + model.camera = { + shape: deriveCameraIsland(deviceName, rawSpecs), + lensCount: deriveLensCount(cameraDesc), + bumpHeightMm: 1.5, + position: "top_left", + }; + + // Features + model.features = { + hasHeadphoneJack: deriveHeadphoneJack(rawSpecs), + fingerprint: deriveFingerprint(rawSpecs), + portType: derivePortType(rawSpecs), + buttonLayout: "power_right_vol_right", + }; + + // Aliases + const aka = rawSpecs["also known as"] || rawSpecs["other names"] || ""; + model.aliases = aka ? aka.split(",").map((s: string) => s.trim()).filter(Boolean) : []; + + // Release year + const launched = rawSpecs["launched"] || rawSpecs["announced"] || ""; + const yearMatch = launched.match(/([0-9]{4})/); + model.releaseYear = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear(); + + // Generate ID + const idBase = (model.brand + "-" + model.name) + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/--+/g, "-") + .replace(/^-|-$/g, ""); + model.id = "gsm-" + idBase + "-" + Date.now(); + + // Notes + const notes: string[] = ["Source: " + sourceUrl]; + if (rawSpecs["chipset"]) notes.push("Chipset: " + rawSpecs["chipset"]); + model.notes = notes.join(" | "); + + // Validate + if (!model.dimensions || !model.screen || !model.camera || !model.features) { + return { success: false, rawSpecs, error: "Incomplete specs - missing required fields." }; + } + + return { success: true, model: model as PhoneModel, rawSpecs }; +} + +// Helper functions + +function deriveShortName(fullName: string): string { + let s = fullName; + const prefixes = ["Samsung ", "Apple ", "Xiaomi ", "Motorola ", "Google ", "Huawei ", "Nokia ", "OnePlus ", "Oppo ", "Realme ", "Honor ", "Vivo "]; + for (const p of prefixes) { + if (s.startsWith(p)) { s = s.slice(p.length); break; } + } + return s; +} + +function deriveBrand(fullName: string): string { + const brands = [ + { prefixes: ["Samsung"], name: "Samsung" }, + { prefixes: ["Apple", "iPhone"], name: "Apple" }, + { prefixes: ["Xiaomi", "Redmi", "Poco", "POCO"], name: "Xiaomi" }, + { prefixes: ["Motorola", "Moto"], name: "Motorola" }, + { prefixes: ["Google", "Pixel"], name: "Google" }, + { prefixes: ["Huawei", "Honor"], name: "Huawei" }, + { prefixes: ["Nokia"], name: "Nokia" }, + ]; + for (const b of brands) { + for (const p of b.prefixes) { + if (fullName.startsWith(p)) return b.name; + } + } + return fullName.split(" ")[0] || "Unknown"; +} + +function deriveAspectRatio(displaySpec: string): string { + const match = displaySpec.match(/([0-9.]+)\s*:\s*([0-9.]+)/); + if (match) return match[1] + ":" + match[2]; + const resMatch = displaySpec.match(/([0-9]+)\s*x\s*([0-9]+)/); + if (resMatch) { + const w = parseInt(resMatch[1], 10); + const h = parseInt(resMatch[2], 10); + if (w > 0 && h > 0) { + const ratio = w / h; + if (ratio > 0.5) return "20:9"; + if (ratio > 0.47) return "19.5:9"; + return "19:9"; + } + } + return "20:9"; +} + +function deriveNotchType(displaySpec: string, deviceName: string): any { + const s = (displaySpec + " " + deviceName).toLowerCase(); + if (s.includes("dynamic island")) return "dynamic_island"; + if (s.includes("punch-hole") || s.includes("punch hole")) return "punch_hole_center"; + if (s.includes("waterdrop") || s.includes("water drop")) return "waterdrop_u"; + if (s.includes("teardrop") || s.includes("v-notch")) return "teardrop_v"; + if (s.includes("notch")) return "wide_notch"; + return "punch_hole_center"; +} + +function deriveLensCount(cameraDesc: string): number { + const mpMatches = cameraDesc.match(/\d+\s*mp/gi); + if (mpMatches) { + const count = mpMatches.length; + if (count > 0 && count <= 5) return count; + } + const s = cameraDesc.toLowerCase(); + if (s.includes("triple")) return 3; + if (s.includes("quad")) return 4; + if (s.includes("dual")) return 2; + return 1; +} + +function deriveCameraIsland(deviceName: string, rawSpecs: Record): any { + const s = (deviceName + " " + JSON.stringify(rawSpecs)).toLowerCase(); + if (s.includes("square")) return "square_island"; + if (s.includes("rectangular")) return "rectangular_island"; + if (s.includes("circular") || s.includes("oreo")) return "circular_oreo"; + if (s.includes("horizontal")) return "horizontal_bar"; + if (s.includes("vertical") || s.includes("teardrop")) return "teardrop_vertical"; + return "individual_rings"; +} + +function deriveHeadphoneJack(rawSpecs: Record): boolean { + const s = (rawSpecs["audio"] || rawSpecs["features"] || "").toLowerCase(); + if (s.includes("3.5mm") || s.includes("jack") || s.includes("headphone")) return true; + const loudspeaker = (rawSpecs["loudspeaker"] || "").toLowerCase(); + if (loudspeaker.includes("3.5mm")) return true; + return false; +} + +function deriveFingerprint(rawSpecs: Record): string { + const s = (rawSpecs["sensors"] || rawSpecs["security"] || rawSpecs["fingerprint"] || "").toLowerCase(); + if (s.includes("under display") || s.includes("optical") || s.includes("ultrasonic")) return "under_display"; + if (s.includes("side") || s.includes("power button")) return "side_power_button"; + if (s.includes("rear") || s.includes("back") || s.includes("fingerprint")) return "rear"; + return "none"; +} + +function derivePortType(rawSpecs: Record): string { + const s = (rawSpecs["usb"] || rawSpecs["ports"] || rawSpecs["charging"] || "").toLowerCase(); + if (s.includes("usb type-c") || s.includes("usb-c") || s.includes("type-c")) return "usb_c"; + if (s.includes("lightning")) return "lightning"; + if (s.includes("micro usb")) return "micro_usb"; + return "usb_c"; +} diff --git a/src/App.tsx b/src/App.tsx index 36cecc3..26a60f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -380,7 +380,7 @@ export const App: React.FC = () => {
diff --git a/src/components/ExternalResearchPanel.tsx b/src/components/ExternalResearchPanel.tsx index c8965d9..98630b9 100644 --- a/src/components/ExternalResearchPanel.tsx +++ b/src/components/ExternalResearchPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Globe, Search, @@ -6,138 +6,158 @@ import { ExternalLink, PlusCircle, CheckCircle, - ShieldAlert, - Star, + AlertTriangle, Info, - Clock + Smartphone, + Maximize, + Camera, + Cpu, + Edit3, + X } from 'lucide-react'; -import { WebResearchItem, PhoneModel, AccessoryCategory } from '../types'; +import { PhoneModel } from '../types'; +import { useLanguage } from '../i18n/translations'; +import { + getResearchFromCache, + saveResearchToCache, + saveNotFoundToCache, + isModelInCatalog, +} from '../utils/researchCache'; interface ExternalResearchPanelProps { initialQuery?: string; - onAddPairToLocal: (item: WebResearchItem) => void; + onAddModel: (model: PhoneModel) => void; existingModels: PhoneModel[]; } -const SIMULATED_RESEARCH_DATABASE: WebResearchItem[] = [ - { - id: 'res-1', - query: 'Xiaomi Poco X6 Neo', - candidateName: 'Redmi Note 13R Pro', - brand: 'Xiaomi', - category: 'all_accessories', - confidenceLevel: 'CONFIRMED_COMPATIBLE', - confidenceScore: 95, - sourceTitle: 'GSMArena OEM Rebrand Hardware Matrix', - sourceUrl: 'https://gsmarena.com/specs/poco_x6_neo.php', - evidenceSnippet: 'Chassis dimensions identical down to 0.05mm: 161.1 x 75.0 x 7.7 mm. Flat 6.67" OLED with centered punch hole. Screen glass and cases are 100% interchangeable.', - specsSummary: '161.1 x 75.0 x 7.7 mm | 6.67" Flat OLED | Rectangular Island', - credibility: 5, - timestamp: '2024-03-15' - }, - { - id: 'res-2', - query: 'Realme 12 5G', - candidateName: 'Realme 12+ 5G', - brand: 'Realme', - category: 'screen_protector', - confidenceLevel: 'CONFIRMED_COMPATIBLE', - confidenceScore: 92, - sourceTitle: 'Display Panel Supply Chain Analysis', - sourceUrl: 'https://devicespecifications.com', - evidenceSnippet: 'Both devices share identical 6.72" 1080x2400 flat front glass panels. Tempered screen protector fits with 100% active display coverage.', - specsSummary: '6.72" 20:9 Flat LCD | Center Punch-Hole', - credibility: 4, - timestamp: '2024-03-10' - }, - { - id: 'res-3', - query: 'Honor Magic6 Lite', - candidateName: 'Honor X9b', - brand: 'Honor', - category: 'all_accessories', - confidenceLevel: 'EXACT_MATCH', - confidenceScore: 100, - sourceTitle: 'Global Hardware Regional Aliases', - sourceUrl: 'https://hihonor.com/specs', - evidenceSnippet: 'Honor X9b is the exact international rebrand of Magic6 Lite. 163.6 x 75.5 x 8.0 mm. Circular camera matrix and 6.78" curved AMOLED match 100%.', - specsSummary: '163.6 x 75.5 x 8.0 mm | 6.78" Curved AMOLED | Circular Island', - credibility: 5, - timestamp: '2024-02-28' - }, - { - id: 'res-4', - query: 'Motorola Moto G24', - candidateName: 'Moto G04', - brand: 'Motorola', - category: 'all_accessories', - confidenceLevel: 'CONFIRMED_COMPATIBLE', - confidenceScore: 98, - sourceTitle: 'Motorola Chassis Mold Teardown', - sourceUrl: 'https://motorola.com/smartphones', - evidenceSnippet: 'Identical body mold (163.5 x 74.5 x 8.0 mm). Both screen protectors and TPU cases are 100% interchangeable.', - specsSummary: '163.5 x 74.5 x 8.0 mm | 6.56" 90Hz LCD | Rectangular 2-ring bump', - credibility: 5, - timestamp: '2024-01-20' - } -]; +interface ResearchState { + loading: boolean; + found: boolean; + model?: PhoneModel; + rawSpecs?: Record; + sourceUrl?: string; + error?: string; +} export const ExternalResearchPanel: React.FC = ({ initialQuery = '', - onAddPairToLocal, - existingModels + onAddModel, + existingModels, }) => { + const { t } = useLanguage(); const [searchQuery, setSearchQuery] = useState(initialQuery); - const [isSearching, setIsSearching] = useState(false); - const [researchResults, setResearchResults] = useState(SIMULATED_RESEARCH_DATABASE); - const [addedIds, setAddedIds] = useState([]); + const [researchState, setResearchState] = useState({ loading: false, found: false }); + const [addedModelIds, setAddedModelIds] = useState>(new Set()); + const [editingSpecs, setEditingSpecs] = useState(false); + const [editableModel, setEditableModel] = useState(null); + + useEffect(() => { + if (initialQuery && initialQuery.trim()) { + setSearchQuery(initialQuery); + } + }, [initialQuery]); - const handleSearch = (e: React.FormEvent) => { + const isAlreadyInCatalog = (model: PhoneModel) => + isModelInCatalog(existingModels, model.id) || addedModelIds.has(model.id); + + const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); - if (!searchQuery.trim()) return; - - setIsSearching(true); - setTimeout(() => { - const q = searchQuery.toLowerCase(); - const filtered = SIMULATED_RESEARCH_DATABASE.filter( - item => item.query.toLowerCase().includes(q) || - item.candidateName.toLowerCase().includes(q) || - item.brand.toLowerCase().includes(q) || - item.evidenceSnippet.toLowerCase().includes(q) - ); - - if (filtered.length > 0) { - setResearchResults(filtered); + const q = searchQuery.trim(); + if (!q) return; + + // Check cache first + const cached = getResearchFromCache(q); + if (cached) { + if (cached.model) { + setResearchState({ + loading: false, + found: true, + model: cached.model, + sourceUrl: undefined, + }); + return; + } + if (cached.notFound) { + setResearchState({ + loading: false, + found: false, + error: `"${q}" — no results found in recent search (cached). Try again later or enter specs manually.`, + }); + return; + } + } + + setResearchState({ loading: true, found: false }); + + try { + const resp = await fetch('/api/v1/research', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: q }), + }); + + const data = await resp.json(); + + if (data.found && data.model) { + saveResearchToCache(data.model); + setResearchState({ + loading: false, + found: true, + model: data.model, + rawSpecs: data.rawSpecs, + sourceUrl: data.sourceUrl, + }); + setEditableModel({ ...data.model }); } else { - // Generate simulated dynamic research candidate based on hardware catalog patterns - const dynamicResearch: WebResearchItem = { - id: `res-gen-${Date.now()}`, - query: searchQuery, - candidateName: `${searchQuery} Global / OEM Twin`, - brand: 'Demo', - category: 'screen_protector', - confidenceLevel: 'HIGHLY_LIKELY', - confidenceScore: 85, - sourceTitle: 'Demo: simulated lookup (no live web data)', - sourceUrl: 'https://gsmarena.com/search.php3', - evidenceSnippet: `[SIMULATED] Placeholder demo result for "${searchQuery}". This is sample data, not a real web lookup.`, - specsSummary: '6.6" Flat Display | Standard USB-C & Port Clearances', - credibility: 4, - timestamp: new Date().toISOString().split('T')[0] - }; - setResearchResults([dynamicResearch]); + saveNotFoundToCache(q); + setResearchState({ + loading: false, + found: false, + error: data.error || `No results found for "${q}".`, + }); } - setIsSearching(false); - }, 600); + } catch (err: any) { + setResearchState({ + loading: false, + found: false, + error: `Research API unavailable: ${err.message || 'Network error'}. You can enter specs manually using the Add Model form.`, + }); + } }; - const handleAdd = (item: WebResearchItem) => { - onAddPairToLocal(item); - setAddedIds(prev => [...prev, item.id]); + const handlePromoteToCatalog = () => { + if (researchState.model && !isAlreadyInCatalog(researchState.model)) { + const modelToAdd = editingSpecs && editableModel ? editableModel : researchState.model; + onAddModel(modelToAdd); + setAddedModelIds(prev => new Set(prev).add(modelToAdd.id)); + setEditingSpecs(false); + } }; - const isSimulated = (item: WebResearchItem) => - item.brand === 'Demo' || item.brand === 'SIMULATED'; + const handleSpecsChange = (field: string, value: any) => { + if (!editableModel) return; + const updated = { ...editableModel }; + + const parts = field.split('.'); + if (parts.length === 2) { + (updated as any)[parts[0]] = { + ...(updated as any)[parts[0]], + [parts[1]]: value, + }; + } else if (parts.length === 3) { + (updated as any)[parts[0]] = { + ...(updated as any)[parts[0]], + [parts[1]]: { + ...((updated as any)[parts[0]]?.[parts[1]] || {}), + [parts[2]]: value, + }, + }; + } else { + (updated as any)[field] = value; + } + + setEditableModel(updated); + }; return (
@@ -149,13 +169,10 @@ export const ExternalResearchPanel: React.FC = ({

- External Web Research - - DEMO - + GSMArena Web Research

- Simulated data only. This panel demonstrates the planned research UI using sample data. No live web search is performed. + Search phone specifications from GSMArena for models not in your local catalog.

@@ -169,148 +186,306 @@ export const ExternalResearchPanel: React.FC = ({ type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder="Search phone model for external research (e.g. Poco X6 Neo, Realme 12, Honor Magic6)..." + placeholder="Search phone model on GSMArena (e.g. Galaxy A06, iPhone 16)..." className="w-full pl-10 pr-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-xl text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:border-blue-500 font-mono" /> - {/* DEMO disclaimer banner */} -
- -
-

- DEMO MODE — simulated research data -

-

- All results below are hard-coded sample data. This panel does not perform live web research, and the confidence scores are illustrative, not real evidence. A future release will replace this with a real spec source and mark results as unverified until staff test them physically. -

-
-
- - {/* Research Results Grid */} -
-
-

- Research Candidates ({researchResults.length}) -

+ {/* Error Messages */} + {researchState.error && !researchState.found && !researchState.loading && ( +
+ +
+

Not found on GSMArena

+

{researchState.error}

+

+ Tip: Try a different search term (brand + model number) or use Add Model form. +

+
+ )} -
- {researchResults.map((item) => { - const isAdded = addedIds.includes(item.id); + {/* Research Result */} + {researchState.found && researchState.model && ( +
+
+

+ + GSMArena Result +

+
+ + RESEARCHED + + {researchState.sourceUrl && ( + + + Source + + )} +
+
- return ( -
+
+
+
+ {researchState.model.fullName} +
+ + {researchState.model.brand} · {researchState.model.releaseYear} + +
+ +
-
- - {item.confidenceScore}% Confidence - -
- {Array.from({ length: item.credibility }).map((_, i) => ( - - ))} -
-
-
- - {/* Physical Specs Summary */} -
- Specs Summary: - {item.specsSummary} -
- - {/* Evidence Snippet */} -
-
- - {item.sourceTitle} + {/* Specs Grid */} +
+
+
+ + Dimensions +
+

+ {researchState.model.dimensions.height} x {researchState.model.dimensions.width} mm +

+

+ Thickness: {researchState.model.dimensions.thickness} mm + {researchState.model.dimensions.weightG && ` (${researchState.model.dimensions.weightG}g)`} +

+
+ +
+
+ + Display +
+

+ {researchState.model.screen.diagonalIn}" · {researchState.model.screen.curvature.toUpperCase()} +

+

+ {researchState.model.screen.notchType.replace(/_/g, ' ')} · {researchState.model.screen.aspectRatio} +

+
+ +
+
+ + Camera +
+

+ {researchState.model.camera.shape.replace(/_/g, ' ')} +

+

+ {researchState.model.camera.lensCount} lenses · {researchState.model.camera.bumpHeightMm}mm bump +

+
+ +
+
+ + Features +
+

+ {researchState.model.features.portType.toUpperCase()} · {researchState.model.features.fingerprint.replace(/_/g, ' ')} +

+

+ 3.5mm: {researchState.model.features.hasHeadphoneJack ? 'Yes' : 'No'} +

+
+
+ + {/* Aliases */} + {researchState.model.aliases && researchState.model.aliases.length > 0 && ( +
+ Aliases: + {researchState.model.aliases.map(alias => ( + + {alias} + + ))} +
+ )} + + {/* Raw Specs */} + {researchState.rawSpecs && Object.keys(researchState.rawSpecs).length > 0 && ( +
+ + View raw GSMArena specifications ({Object.keys(researchState.rawSpecs).length} fields) + +
+ {Object.entries(researchState.rawSpecs).map(([key, value]) => ( +
+ {key}: + {value}
-

- "{item.evidenceSnippet}" -

-
+ ))}
+ + )} - {/* Footer Actions */} -
-
- - Discovered {item.timestamp} -
- -
- -
+ {researchState.model.notes && ( +

{researchState.model.notes}

+ )} +
+ + {/* Edit Specs Button */} + {!editingSpecs && ( +
+ +
+ )} + + {/* Editable Specs Form */} + {editingSpecs && editableModel && ( +
+
+
+ + Edit Specifications +
+ +
+ +
+
+ + handleSpecsChange('brand', e.target.value)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" /> +
+
+ + handleSpecsChange('name', e.target.value)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" /> +
+
+ + handleSpecsChange('releaseYear', parseInt(e.target.value) || 2024)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" />
- ); - })} + +
+
+ + handleSpecsChange('dimensions.height', parseFloat(e.target.value) || 0)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" /> +
+
+ + handleSpecsChange('dimensions.width', parseFloat(e.target.value) || 0)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" /> +
+
+ + handleSpecsChange('dimensions.thickness', parseFloat(e.target.value) || 0)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" /> +
+
+ +
+
+ + handleSpecsChange('screen.diagonalIn', parseFloat(e.target.value) || 0)} className="w-full bg-neutral-900 border border-neutral-700 rounded-lg p-2 text-neutral-200 font-mono" /> +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ )}
-
+ )} + + {/* Empty state */} + {!researchState.loading && !researchState.found && !researchState.error && ( +
+
+ +
+
+

Search GSMArena for a phone model

+

+ Enter a model name above (e.g. "Galaxy A06", "iPhone 16", "Redmi Note 13") + to fetch its physical specifications and add it to your catalog. +

+

+ Results are cached for 24h. Already-catalogued models are skipped automatically. +

+
+
+ )}
); -}; +}; \ No newline at end of file diff --git a/src/utils/researchCache.test.ts b/src/utils/researchCache.test.ts new file mode 100644 index 0000000..5d63d45 --- /dev/null +++ b/src/utils/researchCache.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for the client-side research cache. + * + * Uses localStorage mock via vi.stubGlobal for Node environment. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { PhoneModel } from '../types'; +import { + saveResearchToCache, + getResearchFromCache, + saveNotFoundToCache, + isQueryNotFoundCached, + isModelInCatalog, + cleanExpiredEntries, + getCacheSize, +} from './researchCache'; + +// Mock localStorage for Node environment +function createMockStorage(): Storage { + let store: Record = {}; + return { + getItem(key: string): string | null { return store[key] ?? null; }, + setItem(key: string, value: string): void { store[key] = value; }, + removeItem(key: string): void { delete store[key]; }, + clear(): void { store = {}; }, + get length(): number { return Object.keys(store).length; }, + key(index: number): string | null { return Object.keys(store)[index] ?? null; }, + }; +} + +function makeModel(overrides?: Partial & { id: string }): PhoneModel { + return { + id: overrides?.id || 'test-model', + brand: 'TestBrand', + name: 'Test Model', + fullName: 'TestBrand Test Model', + releaseYear: 2024, + dimensions: { height: 160, width: 75, thickness: 8 }, + screen: { diagonalIn: 6.5, curvature: 'flat', notchType: 'punch_hole_center', aspectRatio: '20:9', hasCurvedEdges: false }, + camera: { shape: 'rectangular_island', lensCount: 3, bumpHeightMm: 1.5, position: 'top_left' }, + features: { hasHeadphoneJack: true, fingerprint: 'side_power_button', portType: 'usb_c', buttonLayout: 'power_right_vol_right' }, + aliases: [], + ...overrides, + }; +} + +describe('researchCache', () => { + beforeEach(() => { + const mockStore = createMockStorage(); + vi.stubGlobal('localStorage', mockStore); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('saves and retrieves a model from cache', () => { + const model = makeModel({ id: 'samsung-a06' }); + saveResearchToCache(model); + + const cached = getResearchFromCache('samsung-a06'); + expect(cached).toBeDefined(); + expect(cached!.model).toBeDefined(); + expect(cached!.model!.id).toBe('samsung-a06'); + expect(cached!.notFound).toBe(false); + }); + + it('returns undefined for uncached models', () => { + const cached = getResearchFromCache('nonexistent'); + expect(cached).toBeUndefined(); + }); + + it('tracks not-found queries', () => { + saveNotFoundToCache('Some Random Phone'); + expect(isQueryNotFoundCached('Some Random Phone')).toBe(true); + expect(isQueryNotFoundCached('some random phone')).toBe(true); + }); + + it('does not find not-found for unregistered queries', () => { + expect(isQueryNotFoundCached('never searched')).toBe(false); + }); + + it('model returns as in catalog via isModelInCatalog', () => { + const models = [makeModel({ id: 'a' }), makeModel({ id: 'b' })]; + expect(isModelInCatalog(models, 'a')).toBe(true); + expect(isModelInCatalog(models, 'c')).toBe(false); + }); + + it('reports cache size correctly', () => { + expect(getCacheSize()).toBe(0); + saveResearchToCache(makeModel({ id: 'm1' })); + saveResearchToCache(makeModel({ id: 'm2' })); + saveResearchToCache(makeModel({ id: 'm3' })); + expect(getCacheSize()).toBe(3); + }); + + it('cleanExpiredEntries does not remove fresh entries', () => { + saveResearchToCache(makeModel({ id: 'fresh-model' })); + cleanExpiredEntries(); + expect(getCacheSize()).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/utils/researchCache.ts b/src/utils/researchCache.ts new file mode 100644 index 0000000..d6a116f --- /dev/null +++ b/src/utils/researchCache.ts @@ -0,0 +1,146 @@ +/** + * researchCache.ts — Client-side cache for researched phone specs. + * + * Caches PhoneModel results fetched from the server to avoid repeated + * network requests. Entries expire after CACHE_TTL_HOURS (default 24h). + * + * Phase 4: also tracks which queries returned "not found" so we don't + * re-query the server for missing models immediately. + */ + +import { PhoneModel } from '../types'; + +interface CacheEntry { + /** ISO timestamp when the entry was saved */ + cachedAt: string; + /** The researched model (undefined if not found) */ + model?: PhoneModel; + /** Whether the original query returned no results */ + notFound: boolean; +} + +const STORAGE_KEY = 'case_screen_checker_research_cache_v1'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const NOT_FOUND_TTL_MS = 60 * 60 * 1000; // 1 hour — retry missing models sooner + +/** + * Check if the model with this id already exists in the main models catalog. + */ +export function isModelInCatalog(models: PhoneModel[], modelId: string): boolean { + return models.some(m => m.id === modelId); +} + +/** + * Check if the model with this id already exists in the research cache. + */ +export function isModelInResearchCache(modelId: string): boolean { + const cache = loadCache(); + return modelId in cache; +} + +/** + * Retrieve a cached researched model by its id. + * Returns undefined if the entry is expired or doesn't exist. + */ +export function getResearchFromCache(modelId: string): { model?: PhoneModel; notFound: boolean } | undefined { + const cache = loadCache(); + const entry = cache[modelId]; + if (!entry) return undefined; + + const age = Date.now() - new Date(entry.cachedAt).getTime(); + const ttl = entry.notFound ? NOT_FOUND_TTL_MS : CACHE_TTL_MS; + + if (age > ttl) { + // Expired — remove and return undefined + delete cache[modelId]; + saveCache(cache); + return undefined; + } + + return { model: entry.model, notFound: entry.notFound }; +} + +/** + * Save a successfully researched model to the cache. + */ +export function saveResearchToCache(model: PhoneModel): void { + const cache = loadCache(); + cache[model.id] = { + cachedAt: new Date().toISOString(), + model, + notFound: false, + }; + saveCache(cache); +} + +/** + * Mark a query as "not found" so we don't immediately re-query. + */ +export function saveNotFoundToCache(query: string): void { + const cache = loadCache(); + const key = `nf:${query.toLowerCase().trim()}`; + cache[key] = { + cachedAt: new Date().toISOString(), + notFound: true, + }; + saveCache(cache); +} + +/** + * Check if a query was recently marked as "not found". + */ +export function isQueryNotFoundCached(query: string): boolean { + const cache = loadCache(); + const key = `nf:${query.toLowerCase().trim()}`; + const entry = cache[key]; + if (!entry) return false; + + const age = Date.now() - new Date(entry.cachedAt).getTime(); + if (age > NOT_FOUND_TTL_MS) { + delete cache[key]; + saveCache(cache); + return false; + } + return true; +} + +/** + * Clear all expired entries from the cache and return the cleaned cache. + */ +export function cleanExpiredEntries(): void { + const cache = loadCache(); + const now = Date.now(); + for (const [key, entry] of Object.entries(cache)) { + const age = now - new Date(entry.cachedAt).getTime(); + const ttl = entry.notFound ? NOT_FOUND_TTL_MS : CACHE_TTL_MS; + if (age > ttl) { + delete cache[key]; + } + } + saveCache(cache); +} + +/** + * Get the total number of cached entries. + */ +export function getCacheSize(): number { + return Object.keys(loadCache()).length; +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +function loadCache(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch {} + return {}; +} + +function saveCache(cache: Record): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); + } catch { + // localStorage full or blocked — silently fail + } +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 7eeb3f8..85c7d1f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'server/**/*.test.ts'], }, });