Monitors product pages on [store.ui.com](https://store.ui.com/us/en) for stock availability and fires a Telegram alert the moment a watched item comes back in stock. Runs as a single self-contained Docker container on Unraid with a web UI for managing tracked products.
**Status:** Complete and running.
## How It Works
A Puppeteer scraper visits each watched product URL on a per-item timer, waits for the React page to hydrate, and classifies the page as `in_stock`, `sold_out`, or `unknown` by inspecting the Add-to-Cart / notify buttons. When an item transitions to `in_stock`, the backend sends a one-time Telegram alert. You re-arm the item in the UI to enable the next alert, so you get notified without being spammed.
Stock detection rules:
- A `<button>` with `label="Add to Cart"`, or whose text contains `"add to cart"` → `in_stock`.
- A `<button>` whose text contains `"login for notifications"`, `"login for updates"`, `"notify me when available"`, `"notify me"`, `"sold out"`, `"out of stock"`, `"currently unavailable"`, or `"coming soon"` → `sold_out`.
- Neither found → `unknown` (logs a debug payload with page title and button texts).
The scraper runs logged out, so Ubiquiti shows `"Login for Notifications"` on sold-out items — that string is in the sold-out list by design. Product name comes from the `og:title` meta tag (store suffix stripped) and the thumbnail from `og:image`.
The SQLite database lives on a mounted volume at `/app/data/tracker.db`, so it persists across rebuilds.
Backend runtime behavior:
- **Scheduler** — each active item runs its own in-memory `setInterval`; timers start automatically on boot from the DB. Minimum interval is **30 seconds** (enforced backend and frontend). Pause / Resume / Edit update both the timer map and the DB immediately.
- **Concurrency** — a semaphore caps concurrent Puppeteer instances at **2** to avoid OOM on Unraid.
- **Alert logic** — an alert fires once when an item goes `in_stock` with `alert_sent = 0`, then sets `alert_sent = 1`. **Re-arm** in the UI resets it to `0`.
- **Frontend polling** — the UI polls `GET /api/items` every **10 seconds**.
## Repository Layout
```
ui-tracker/
├── Dockerfile # 3-stage single-container build
├── docker-compose.yml # Single service, port 8080, ./data volume
├── nginx.conf # Serves frontend, proxies /api/ to Node
├── supervisord.conf # Keeps nginx + node alive
├── .gitea/workflows/ # CI: build + push image on push to main
├── data/ # SQLite DB (volume mount)
├── backend/
│ └── src/
│ ├── index.ts # Express entry + scheduler init
│ ├── database.ts # Schema, getDb(), WatchedItem type
The backend and frontend run independently in dev.
```bash
# Backend (http://localhost:3001)
cd backend
npm install
npm run dev # ts-node-dev, respawns on change
# Frontend (http://localhost:5173, Vite)
cd frontend
npm install
npm run dev
```
Backend env vars (set automatically in the container via `supervisord.conf`): `PORT` (default `3001`), `DATABASE_PATH` (default `/app/data/tracker.db`), `PUPPETEER_EXECUTABLE_PATH` (default `/usr/bin/chromium`).
> Lockfiles are not committed, so the Docker build uses `npm install` rather than `npm ci`.
## Build & Run (Docker)
```bash
docker build -t ui-tracker .
docker compose up -d # serves on http://localhost:8080
```
The image builds in three stages: Vite builds the frontend, `tsc` compiles the backend (then `npm prune --production`), and the runtime stage assembles `node:20-slim` + nginx + Chromium + supervisor.
Full Unraid install instructions are in [UNRAID.md](./UNRAID.md).
## CI/CD
`.gitea/workflows/docker-build.yml` builds and pushes the image on every push to `main`:
1. Checks out the repo.
2. Logs in to the private registry at `registry.alwisp.com` (`secrets.REGISTRY_USER` / `secrets.REGISTRY_TOKEN`).
3. Builds and pushes `registry.alwisp.com/{owner}/{repo}:latest`.