From 18a72dce5dcc914677dce3894f24d68d2eca89b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 14:33:01 +0000 Subject: [PATCH 01/15] Add venue carousel tracker web app Scrapes homepage carousel tiles for all 20 O2 Academy venues and Edinburgh Corn Exchange, surfaces outdated and expiring-soon tiles on a live dashboard, and sends alerts via email, Slack, and/or webhook when tiles need replacing. - server.js: Express server with axios+cheerio scraping, hourly cron job, SSE real-time updates, and nodemailer/Slack/webhook notifications - public/index.html: Dashboard with summary bar, filter buttons, and venue cards - public/app.js: SSE client, browser notifications, filter/refresh UI - public/style.css: Colour-coded status indicators (red/orange/green) - .env.example: SMTP, Slack, and webhook configuration template https://claude.ai/code/session_01JoKM72wEiTc7vswPJGxsSM --- .env.example | 22 + .gitignore | 3 + README.md | 87 ++- package-lock.json | 1287 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 + public/app.js | 230 ++++++++ public/index.html | 121 +++++ public/style.css | 452 ++++++++++++++++ server.js | 336 ++++++++++++ 9 files changed, 2555 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..437b554 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# ── Server ────────────────────────────────────────────────────────────────── +PORT=3000 + +# ── Email Notifications (SMTP) ─────────────────────────────────────────────── +# Leave blank to disable email alerts +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=you@example.com +SMTP_PASS=yourpassword +SMTP_FROM=alerts@example.com +# Comma-separated list of recipient addresses +NOTIFY_EMAIL=team@example.com + +# ── Slack Notifications ────────────────────────────────────────────────────── +# Leave blank to disable Slack alerts +# Create an Incoming Webhook at https://api.slack.com/messaging/webhooks +SLACK_WEBHOOK_URL= + +# ── Generic Webhook ────────────────────────────────────────────────────────── +# Leave blank to disable. POST payload: { event, timestamp, venues[] } +WEBHOOK_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e8157a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.log diff --git a/README.md b/README.md index fe4178b..a82b863 100644 --- a/README.md +++ b/README.md @@ -1 +1,86 @@ -# Web-tracker \ No newline at end of file +# Venue Carousel Tracker + +A web dashboard that monitors homepage carousel tiles across all O2 Academy venues and Edinburgh Corn Exchange, alerting the team when tiles are outdated and need replacing. + +## What It Does + +- Scrapes the homepage of all 20 venues every hour +- Detects carousel tiles where the event date has **already passed** (outdated) or is **within 7 days** (expiring soon) +- Displays a live dashboard with colour-coded status for each venue and tile +- Sends alerts via **email**, **Slack**, and/or a **generic webhook** when new outdated tiles are found +- Shows **browser desktop notifications** when the dashboard is open + +## Setup + +### 1. Install dependencies + +```bash +npm install +``` + +### 2. Configure notifications (optional) + +```bash +cp .env.example .env +``` + +Edit `.env` with your SMTP / Slack / webhook details. All notification channels are optional — leave them blank to disable. + +### 3. Start the server + +```bash +npm start +``` + +Open **http://localhost:3000** in your browser. + +## Venues Tracked + +| Venue | URL | +|---|---| +| O2 Academy Birmingham | academymusicgroup.com/o2academybirmingham | +| O2 Academy Bournemouth | academymusicgroup.com/o2academybournemouth | +| O2 Academy Bristol | academymusicgroup.com/o2academybristol | +| O2 Academy Brixton | academymusicgroup.com/o2academybrixton | +| O2 Academy Glasgow | academymusicgroup.com/o2academyglasgow | +| O2 Academy Islington | academymusicgroup.com/o2academyislington | +| O2 Academy Leeds | academymusicgroup.com/o2academyleeds | +| O2 Academy Leicester | academymusicgroup.com/o2academyleicester | +| O2 Academy Liverpool | academymusicgroup.com/o2academyliverpool | +| O2 Academy Oxford | academymusicgroup.com/o2academyoxford | +| O2 Academy Sheffield | academymusicgroup.com/o2academysheffield | +| O2 Apollo Manchester | academymusicgroup.com/o2apollomanchester | +| O2 City Hall Newcastle | academymusicgroup.com/o2cityhallnewcastle | +| O2 Forum Kentish Town | academymusicgroup.com/o2forumkentishtown | +| O2 Guildhall Southampton | academymusicgroup.com/o2guildhallsouthampton | +| O2 Institute Birmingham | academymusicgroup.com/o2institutebirmingham | +| O2 Ritz Manchester | academymusicgroup.com/o2ritzmanchester | +| O2 Shepherd's Bush Empire | academymusicgroup.com/o2shepherdsbushempire | +| O2 Victoria Warehouse Manchester | academymusicgroup.com/o2victoriawarehousemanchester | +| Edinburgh Corn Exchange | edinburghcornexchange.co.uk | + +## Status Indicators + +| Colour | Meaning | +|---|---| +| 🔴 Red | Event date has passed — tile needs replacing immediately | +| 🟠 Orange | Event is within 7 days — tile will need replacing soon | +| 🟢 Green | Event is more than 7 days away — tile is fine | + +## API + +| Endpoint | Description | +|---|---| +| `GET /api/status` | Returns current venue data and last checked time | +| `POST /api/refresh` | Triggers an immediate scrape of all venues | +| `GET /api/events` | Server-Sent Events stream for real-time dashboard updates | + +## Notifications + +When a venue's carousel tiles become outdated, the server automatically sends alerts via any configured channel: + +- **Email** — uses nodemailer with any SMTP provider (Gmail, SendGrid, etc.) +- **Slack** — via Incoming Webhook +- **Generic Webhook** — HTTP POST with JSON payload + +Alerts are only sent when a venue *newly* becomes outdated (not on every hourly check). diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9eed21f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1287 @@ +{ + "name": "venue-carousel-tracker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "venue-carousel-tracker", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "cheerio": "^1.0.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.7" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "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/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "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/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", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "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/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "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-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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-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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2831d09 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "venue-carousel-tracker", + "version": "1.0.0", + "description": "Tracks homepage carousel tiles across O2 Academy venues and alerts when they are outdated", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "npx nodemon server.js" + }, + "dependencies": { + "axios": "^1.6.0", + "cheerio": "^1.0.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.7" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..cc61440 --- /dev/null +++ b/public/app.js @@ -0,0 +1,230 @@ +/* ── Venue Carousel Tracker — Frontend ── */ + +let allVenueData = {}; +let currentFilter = 'all'; +let notifyPermission = Notification.permission; +let alertedOutdated = new Set(); // track which have already triggered a notification this session + +// ── Utilities ────────────────────────────────────────────────────────────── + +function daysRelative(isoDate) { + if (!isoDate) return null; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const d = new Date(isoDate); + d.setHours(0, 0, 0, 0); + return Math.floor((d - today) / 86400000); +} + +function formatRelative(isoDate) { + const days = daysRelative(isoDate); + if (days === null) return ''; + if (days === 0) return 'Today'; + if (days === 1) return 'Tomorrow'; + if (days === -1) return '1 day ago'; + if (days < 0) return `${Math.abs(days)}d ago`; + if (days <= 7) return `${days}d away`; + return ''; +} + +function formatLastChecked(iso) { + if (!iso) return '—'; + const d = new Date(iso); + return d.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); +} + +function statusLabel(status) { + return { outdated: 'Outdated', expiring_soon: 'Expiring Soon', ok: 'Up to Date', error: 'Error', unknown: 'Unknown' }[status] || status; +} + +// ── Render ───────────────────────────────────────────────────────────────── + +function renderSummary(venues) { + const arr = Object.values(venues); + const outdated = arr.filter(v => v.overallStatus === 'outdated').length; + const expiring = arr.filter(v => v.overallStatus === 'expiring_soon').length; + const ok = arr.filter(v => v.overallStatus === 'ok').length; + document.getElementById('count-outdated').textContent = outdated; + document.getElementById('count-expiring').textContent = expiring; + document.getElementById('count-ok').textContent = ok; + document.getElementById('count-total').textContent = arr.length; +} + +function buildTileHTML(tile) { + const rel = formatRelative(tile.date); + return ` +
+
+
+
${esc(tile.artist)}
+
${esc(tile.dateStr)}
+
+ ${rel ? `${rel}` : ''} +
`; +} + +function buildCardHTML(venue) { + const tilesHTML = venue.tiles.length + ? venue.tiles.map(buildTileHTML).join('') + : '
No carousel tiles found.
'; + + const errorHTML = venue.error + ? `
+ + ${esc(venue.error)} +
` : ''; + + return ` +
+
+ ${esc(venue.name)} + ${statusLabel(venue.overallStatus)} +
+
+ ${errorHTML || tilesHTML} +
+ +
`; +} + +function renderVenues(venues) { + const grid = document.getElementById('venue-grid'); + const arr = Object.values(venues); + + const filtered = currentFilter === 'all' + ? arr + : arr.filter(v => v.overallStatus === currentFilter); + + if (filtered.length === 0) { + grid.innerHTML = `

No venues match this filter.

`; + return; + } + + // Sort: outdated first, then expiring, then ok, then error + const order = { outdated: 0, expiring_soon: 1, ok: 2, unknown: 3, error: 4 }; + filtered.sort((a, b) => (order[a.overallStatus] ?? 9) - (order[b.overallStatus] ?? 9)); + + grid.innerHTML = filtered.map(buildCardHTML).join(''); +} + +function esc(str) { + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// ── Browser Notifications ────────────────────────────────────────────────── + +async function requestNotificationPermission() { + if (!('Notification' in window)) return; + if (Notification.permission === 'granted') { + notifyPermission = 'granted'; + updateNotifyBtn(); + return; + } + const perm = await Notification.requestPermission(); + notifyPermission = perm; + updateNotifyBtn(); +} + +function updateNotifyBtn() { + const btn = document.getElementById('notify-btn'); + if (notifyPermission === 'granted') { + btn.textContent = 'Alerts On'; + btn.style.background = 'rgba(22,163,74,.3)'; + } +} + +function triggerBrowserNotifications(venues) { + if (notifyPermission !== 'granted') return; + const outdated = Object.values(venues).filter(v => v.overallStatus === 'outdated' && !alertedOutdated.has(v.name)); + outdated.forEach(v => { + alertedOutdated.add(v.name); + const outdatedTiles = v.tiles.filter(t => t.status === 'outdated'); + new Notification(`Outdated tiles: ${v.name}`, { + body: outdatedTiles.map(t => `${t.artist} — ${t.dateStr}`).join('\n'), + icon: '/favicon.ico', + tag: v.name, + }); + }); +} + +// ── SSE Connection ───────────────────────────────────────────────────────── + +function connectSSE() { + const evtSource = new EventSource('/api/events'); + + evtSource.onmessage = (e) => { + const msg = JSON.parse(e.data); + if (msg.type === 'scraping_started') { + document.getElementById('scraping-banner').classList.remove('hidden'); + document.getElementById('refresh-btn').disabled = true; + } else if (msg.type === 'update') { + document.getElementById('scraping-banner').classList.add('hidden'); + document.getElementById('refresh-btn').disabled = false; + document.getElementById('last-checked').textContent = formatLastChecked(msg.lastChecked); + allVenueData = msg.data || {}; + renderSummary(allVenueData); + renderVenues(allVenueData); + triggerBrowserNotifications(allVenueData); + } + }; + + evtSource.onerror = () => { + // Reconnect after 5 seconds + evtSource.close(); + setTimeout(connectSSE, 5000); + }; +} + +// ── Filter Buttons ───────────────────────────────────────────────────────── + +document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentFilter = btn.dataset.filter; + renderVenues(allVenueData); + }); +}); + +// ── Refresh Button ───────────────────────────────────────────────────────── + +document.getElementById('refresh-btn').addEventListener('click', async () => { + document.getElementById('refresh-btn').disabled = true; + document.getElementById('scraping-banner').classList.remove('hidden'); + try { + await fetch('/api/refresh', { method: 'POST' }); + } catch (err) { + document.getElementById('scraping-banner').classList.add('hidden'); + document.getElementById('refresh-btn').disabled = false; + } +}); + +// ── Notification Button ──────────────────────────────────────────────────── + +document.getElementById('notify-btn').addEventListener('click', requestNotificationPermission); + +// ── Settings Panel ───────────────────────────────────────────────────────── + +document.getElementById('open-settings').addEventListener('click', () => { + document.getElementById('settings-panel').classList.remove('hidden'); +}); + +document.getElementById('close-settings').addEventListener('click', () => { + document.getElementById('settings-panel').classList.add('hidden'); +}); + +document.getElementById('settings-panel').addEventListener('click', (e) => { + if (e.target === document.getElementById('settings-panel')) { + document.getElementById('settings-panel').classList.add('hidden'); + } +}); + +// ── Init ─────────────────────────────────────────────────────────────────── + +updateNotifyBtn(); +connectSSE(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..18df21f --- /dev/null +++ b/public/index.html @@ -0,0 +1,121 @@ + + + + + + Venue Carousel Tracker + + + + + + + + +
+
+ + Outdated +
+
+ + Expiring Soon +
+
+ + Up to Date +
+
+ + Venues +
+
+ + + + + +
+ + + + + +
+ + +
+
+
+

Loading venue data…

+
+
+ + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..a36e533 --- /dev/null +++ b/public/style.css @@ -0,0 +1,452 @@ +/* ── Reset & Variables ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --red: #dc2626; + --red-light: #fef2f2; + --red-border: #fca5a5; + --orange: #d97706; + --orange-light:#fffbeb; + --orange-border:#fcd34d; + --green: #16a34a; + --green-light:#f0fdf4; + --green-border:#86efac; + --gray: #6b7280; + --gray-light: #f9fafb; + --gray-border:#e5e7eb; + --bg: #f1f5f9; + --surface: #ffffff; + --text: #111827; + --text-muted: #6b7280; + --border: #e2e8f0; + --radius: 12px; + --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06); + --shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Header ── */ +.site-header { + background: #1e293b; + color: #fff; + padding: 1.25rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.site-header h1 { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -.02em; +} + +.header-subtitle { + font-size: 0.8rem; + color: #94a3b8; + margin-top: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.last-checked-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 0.75rem; +} + +.last-checked-wrap .label { color: #94a3b8; } +.last-checked-time { color: #e2e8f0; font-weight: 500; } + +/* ── Buttons ── */ +.btn-primary, .btn-secondary { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 1rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: background .15s, opacity .15s; +} + +.btn-primary { + background: #3b82f6; + color: #fff; +} +.btn-primary:hover { background: #2563eb; } +.btn-primary:disabled { opacity: .55; cursor: not-allowed; } + +.btn-secondary { + background: rgba(255,255,255,.12); + color: #e2e8f0; + border: 1px solid rgba(255,255,255,.18); +} +.btn-secondary:hover { background: rgba(255,255,255,.2); } + +/* ── Summary Bar ── */ +.summary-bar { + display: flex; + gap: 1rem; + padding: 1rem 2rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.summary-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.6rem 1.5rem; + border-radius: 10px; + border: 1.5px solid var(--gray-border); + background: var(--gray-light); + min-width: 100px; +} + +.summary-count { + font-size: 1.8rem; + font-weight: 800; + line-height: 1; +} + +.summary-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-muted); + margin-top: 4px; +} + +.summary-outdated { border-color: var(--red-border); background: var(--red-light); } +.summary-outdated .summary-count { color: var(--red); } + +.summary-expiring { border-color: var(--orange-border); background: var(--orange-light); } +.summary-expiring .summary-count { color: var(--orange); } + +.summary-ok { border-color: var(--green-border); background: var(--green-light); } +.summary-ok .summary-count { color: var(--green); } + +.summary-total .summary-count { color: #334155; } + +/* ── Scraping Banner ── */ +.scraping-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 2rem; + background: #eff6ff; + border-bottom: 1px solid #bfdbfe; + color: #1e40af; + font-size: 0.875rem; + font-weight: 500; +} + +/* ── Filter Bar ── */ +.filter-bar { + display: flex; + gap: 0.5rem; + padding: 1rem 2rem 0; + flex-wrap: wrap; +} + +.filter-btn { + padding: 0.35rem 0.9rem; + border-radius: 20px; + border: 1.5px solid var(--border); + background: var(--surface); + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + transition: all .15s; +} + +.filter-btn:hover { border-color: #94a3b8; color: var(--text); } +.filter-btn.active { background: #1e293b; color: #fff; border-color: #1e293b; } + +/* ── Venue Grid ── */ +.venue-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 1.25rem; + padding: 1.25rem 2rem 2rem; + flex: 1; +} + +/* ── Venue Card ── */ +.venue-card { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + border-left: 4px solid var(--gray-border); + overflow: hidden; + display: flex; + flex-direction: column; + transition: box-shadow .2s; +} + +.venue-card:hover { box-shadow: var(--shadow-md); } + +.venue-card.status-outdated { border-left-color: var(--red); } +.venue-card.status-expiring_soon { border-left-color: var(--orange); } +.venue-card.status-ok { border-left-color: var(--green); } +.venue-card.status-error { border-left-color: var(--gray); } + +.venue-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 1rem 1.1rem 0.75rem; + gap: 0.5rem; +} + +.venue-name { + font-size: 0.95rem; + font-weight: 700; + color: var(--text); + text-decoration: none; + line-height: 1.3; +} + +.venue-name:hover { text-decoration: underline; color: #2563eb; } + +.venue-badge { + flex-shrink: 0; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + padding: 0.2rem 0.55rem; + border-radius: 20px; +} + +.badge-outdated { background: var(--red-light); color: var(--red); border: 1px solid var(--red-border); } +.badge-expiring_soon{ background: var(--orange-light); color: var(--orange); border: 1px solid var(--orange-border); } +.badge-ok { background: var(--green-light); color: var(--green); border: 1px solid var(--green-border); } +.badge-error { background: var(--gray-light); color: var(--gray); border: 1px solid var(--gray-border); } +.badge-unknown { background: var(--gray-light); color: var(--gray); border: 1px solid var(--gray-border); } + +/* ── Tile List ── */ +.tile-list { + padding: 0 1.1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + flex: 1; +} + +.tile-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.65rem; + border-radius: 8px; + background: #f8fafc; + border: 1px solid var(--border); +} + +.tile-item.tile-outdated { background: var(--red-light); border-color: var(--red-border); } +.tile-item.tile-expiring_soon{ background: var(--orange-light); border-color: var(--orange-border); } +.tile-item.tile-ok { background: var(--green-light); border-color: var(--green-border); } + +.tile-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.tile-outdated .tile-dot { background: var(--red); } +.tile-expiring_soon .tile-dot { background: var(--orange); } +.tile-ok .tile-dot { background: var(--green); } +.tile-unknown .tile-dot { background: var(--gray); } + +.tile-info { + flex: 1; + min-width: 0; +} + +.tile-artist { + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} + +.tile-date { + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 1px; +} + +.tile-outdated .tile-date { color: var(--red); font-weight: 600; } +.tile-expiring_soon .tile-date{ color: var(--orange); font-weight: 600; } + +.tile-days-ago { + font-size: 0.68rem; + font-weight: 700; + flex-shrink: 0; + padding: 0.15rem 0.45rem; + border-radius: 4px; +} + +.tile-outdated .tile-days-ago { background: var(--red); color: #fff; } +.tile-expiring_soon .tile-days-ago { background: var(--orange); color: #fff; } +.tile-ok .tile-days-ago { color: var(--green); } + +/* ── Error State ── */ +.venue-error { + font-size: 0.78rem; + color: var(--gray); + padding: 0.75rem 1.1rem 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* ── Card Footer ── */ +.venue-card-footer { + padding: 0.5rem 1.1rem 0.75rem; + border-top: 1px solid var(--border); + font-size: 0.72rem; + color: var(--text-muted); +} + +/* ── Loading Placeholder ── */ +.loading-placeholder { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 3rem; + color: var(--text-muted); +} + +/* ── Spinner ── */ +.spinner { + width: 20px; + height: 20px; + border: 3px solid #e2e8f0; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin .7s linear infinite; + flex-shrink: 0; +} + +.spinner.large { width: 36px; height: 36px; border-width: 4px; } + +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Settings Panel ── */ +.settings-panel { + position: fixed; + inset: 0; + background: rgba(0,0,0,.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + padding: 1rem; +} + +.settings-card { + background: var(--surface); + border-radius: var(--radius); + padding: 2rem; + max-width: 700px; + width: 100%; + box-shadow: 0 20px 60px rgba(0,0,0,.3); +} + +.settings-card h2 { font-size: 1.2rem; margin-bottom: 0.5rem; } + +.settings-description { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.settings-section h3 { + font-size: 0.9rem; + margin-bottom: 0.5rem; + color: #334155; +} + +.env-block { + display: block; + background: #1e293b; + color: #7dd3fc; + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.78rem; + line-height: 1.7; + white-space: pre; +} + +/* ── Footer ── */ +.site-footer { + padding: 0.75rem 2rem; + border-top: 1px solid var(--border); + background: var(--surface); + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.78rem; + color: var(--text-muted); +} + +.settings-link { + background: none; + border: none; + color: #3b82f6; + font-size: 0.78rem; + cursor: pointer; + text-decoration: underline; +} + +.settings-link:hover { color: #2563eb; } + +/* ── Utility ── */ +.hidden { display: none !important; } + +/* ── Responsive ── */ +@media (max-width: 600px) { + .site-header { padding: 1rem; } + .summary-bar { padding: 0.75rem 1rem; } + .filter-bar { padding: 0.75rem 1rem 0; } + .venue-grid { padding: 1rem; grid-template-columns: 1fr; } + .settings-grid { grid-template-columns: 1fr; } + .header-right { width: 100%; justify-content: flex-end; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..4802808 --- /dev/null +++ b/server.js @@ -0,0 +1,336 @@ +require('dotenv').config(); +const express = require('express'); +const axios = require('axios'); +const cheerio = require('cheerio'); +const cron = require('node-cron'); +const nodemailer = require('nodemailer'); +const path = require('path'); + +const app = express(); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +// ─── Venue List ────────────────────────────────────────────────────────────── + +const VENUES = [ + { name: 'O2 Academy Birmingham', url: 'https://www.academymusicgroup.com/o2academybirmingham', type: 'amg' }, + { name: 'O2 Academy Bournemouth', url: 'https://www.academymusicgroup.com/o2academybournemouth', type: 'amg' }, + { name: 'O2 Academy Bristol', url: 'https://www.academymusicgroup.com/o2academybristol', type: 'amg' }, + { name: 'O2 Academy Brixton', url: 'https://www.academymusicgroup.com/o2academybrixton', type: 'amg' }, + { name: 'O2 Academy Glasgow', url: 'https://www.academymusicgroup.com/o2academyglasgow', type: 'amg' }, + { name: 'O2 Academy Islington', url: 'https://www.academymusicgroup.com/o2academyislington', type: 'amg' }, + { name: 'O2 Academy Leeds', url: 'https://www.academymusicgroup.com/o2academyleeds', type: 'amg' }, + { name: 'O2 Academy Leicester', url: 'https://www.academymusicgroup.com/o2academyleicester', type: 'amg' }, + { name: 'O2 Academy Liverpool', url: 'https://www.academymusicgroup.com/o2academyliverpool', type: 'amg' }, + { name: 'O2 Academy Oxford', url: 'https://www.academymusicgroup.com/o2academyoxford', type: 'amg' }, + { name: 'O2 Academy Sheffield', url: 'https://www.academymusicgroup.com/o2academysheffield', type: 'amg' }, + { name: 'O2 Apollo Manchester', url: 'https://www.academymusicgroup.com/o2apollomanchester', type: 'amg' }, + { name: 'O2 City Hall Newcastle', url: 'https://www.academymusicgroup.com/o2cityhallnewcastle', type: 'amg' }, + { name: 'O2 Forum Kentish Town', url: 'https://www.academymusicgroup.com/o2forumkentishtown', type: 'amg' }, + { name: 'O2 Guildhall Southampton', url: 'https://www.academymusicgroup.com/o2guildhallsouthampton', type: 'amg' }, + { name: 'O2 Institute Birmingham', url: 'https://www.academymusicgroup.com/o2institutebirmingham', type: 'amg' }, + { name: 'O2 Ritz Manchester', url: 'https://www.academymusicgroup.com/o2ritzmanchester', type: 'amg' }, + { name: "O2 Shepherd's Bush Empire", url: 'https://www.academymusicgroup.com/o2shepherdsbushempire', type: 'amg' }, + { name: 'O2 Victoria Warehouse Manchester',url: 'https://www.academymusicgroup.com/o2victoriawarehousemanchester',type: 'amg' }, + { name: 'Edinburgh Corn Exchange', url: 'https://www.edinburghcornexchange.co.uk', type: 'ece' }, +]; + +// ─── State ─────────────────────────────────────────────────────────────────── + +let venueData = {}; +let lastChecked = null; +let isScraping = false; +const previouslyOutdated = new Set(); +const sseClients = []; + +// ─── Date Helpers ───────────────────────────────────────────────────────────── + +const MONTH_MAP = { + january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, + july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +function parseEventDate(dateStr) { + if (!dateStr) return null; + // "Friday 9 October 2026" or "9 October 2026" + let m = dateStr.match(/(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})/i); + if (m) return new Date(+m[3], MONTH_MAP[m[2].toLowerCase()], +m[1]); + // "28 JUL 2026" + m = dateStr.match(/(\d{1,2})\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(\d{4})/i); + if (m) return new Date(+m[3], MONTH_MAP[m[2].toLowerCase()], +m[1]); + return null; +} + +function getTileStatus(date) { + if (!date) return 'unknown'; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const daysUntil = Math.floor((date - today) / 86400000); + if (daysUntil < 0) return 'outdated'; + if (daysUntil <= 7) return 'expiring_soon'; + return 'ok'; +} + +// ─── Scrapers ───────────────────────────────────────────────────────────────── + +const HTTP_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-GB,en;q=0.9', +}; + +async function scrapeAMG(url) { + const { data } = await axios.get(url, { headers: HTTP_HEADERS, timeout: 20000 }); + const $ = cheerio.load(data); + const tiles = []; + const seen = new Set(); + + $('[data-testid="carousel-slide"]').each((_, el) => { + const artist = $('h2, h3', el).first().text().trim(); + const dateStr = $('p', el).first().text().trim(); + if (!artist) return; + const key = `${artist}|${dateStr}`; + if (seen.has(key)) return; + seen.add(key); + const date = parseEventDate(dateStr); + tiles.push({ artist, dateStr, date: date ? date.toISOString() : null, status: getTileStatus(date) }); + }); + + return tiles; +} + +async function scrapeECE(url) { + const { data } = await axios.get(url, { headers: HTTP_HEADERS, timeout: 20000 }); + const $ = cheerio.load(data); + const tiles = []; + const seen = new Set(); + + // Edinburgh's "What's new" carousel is the first module-content-list-medium section + $('[data-testid="module-content-list-medium"]').first() + .find('[data-testid="module-content-list-item"]').each((_, el) => { + const artist = $('[data-testid="module-content-list-item-title"]', el).text().trim(); + const dateStr = $('[data-testid="module-content-list-item-subtitle"]', el).text().trim(); + if (!artist) return; + const key = `${artist}|${dateStr}`; + if (seen.has(key)) return; + seen.add(key); + const date = parseEventDate(dateStr); + tiles.push({ artist, dateStr, date: date ? date.toISOString() : null, status: getTileStatus(date) }); + }); + + return tiles; +} + +async function scrapeVenue(venue) { + try { + const tiles = venue.type === 'amg' ? await scrapeAMG(venue.url) : await scrapeECE(venue.url); + const outdatedCount = tiles.filter(t => t.status === 'outdated').length; + const expiringCount = tiles.filter(t => t.status === 'expiring_soon').length; + const overallStatus = outdatedCount > 0 ? 'outdated' + : expiringCount > 0 ? 'expiring_soon' + : tiles.length > 0 ? 'ok' + : 'unknown'; + return { name: venue.name, url: venue.url, tiles, outdatedCount, expiringCount, overallStatus, error: null, checkedAt: new Date().toISOString() }; + } catch (err) { + console.error(`Error scraping ${venue.name}:`, err.message); + return { name: venue.name, url: venue.url, tiles: [], outdatedCount: 0, expiringCount: 0, overallStatus: 'error', error: err.message, checkedAt: new Date().toISOString() }; + } +} + +// ─── Main Scrape ────────────────────────────────────────────────────────────── + +async function scrapeAll() { + if (isScraping) return; + isScraping = true; + console.log(`[${new Date().toISOString()}] Scraping all venues...`); + + broadcastSSE({ type: 'scraping_started' }); + + // Scrape all venues with a concurrency cap of 5 + const results = []; + for (let i = 0; i < VENUES.length; i += 5) { + const batch = VENUES.slice(i, i + 5); + const batchResults = await Promise.all(batch.map(scrapeVenue)); + results.push(...batchResults); + } + + const newlyOutdated = []; + const newData = {}; + + results.forEach(result => { + newData[result.name] = result; + const isNowOutdated = result.overallStatus === 'outdated'; + if (isNowOutdated && !previouslyOutdated.has(result.name)) { + newlyOutdated.push(result); + } + if (isNowOutdated) { + previouslyOutdated.add(result.name); + } else { + previouslyOutdated.delete(result.name); + } + }); + + venueData = newData; + lastChecked = new Date().toISOString(); + isScraping = false; + + broadcastSSE({ type: 'update', data: venueData, lastChecked }); + + if (newlyOutdated.length > 0) { + console.log(`Newly outdated venues: ${newlyOutdated.map(v => v.name).join(', ')}`); + await sendEmailNotification(newlyOutdated); + await sendSlackNotification(newlyOutdated); + await sendWebhookNotification(newlyOutdated); + } + + console.log(`[${new Date().toISOString()}] Scrape complete. ${results.length} venues checked.`); +} + +// ─── Notifications ──────────────────────────────────────────────────────────── + +async function sendEmailNotification(outdatedVenues) { + if (!process.env.SMTP_HOST || !process.env.NOTIFY_EMAIL) return; + try { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587'), + secure: process.env.SMTP_SECURE === 'true', + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + }); + + const venueRows = outdatedVenues.map(v => { + const outdatedTiles = v.tiles.filter(t => t.status === 'outdated'); + const rows = outdatedTiles.map(t => + `${t.artist}${t.dateStr}` + ).join(''); + return ` + + ${v.name} + ${rows}`; + }).join(''); + + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: process.env.NOTIFY_EMAIL, + subject: `[Venue Tracker] ${outdatedVenues.length} venue(s) have outdated homepage tiles`, + html: ` +

Outdated Homepage Carousel Tiles

+

The following venues have carousel tiles showing events that have already passed. Please replace them.

+ + + + + + ${venueRows} +
ShowDate on Tile
+

Sent by Venue Carousel Tracker at ${new Date().toUTCString()}

+ `, + }); + console.log('Email notification sent to', process.env.NOTIFY_EMAIL); + } catch (err) { + console.error('Email notification failed:', err.message); + } +} + +async function sendSlackNotification(outdatedVenues) { + if (!process.env.SLACK_WEBHOOK_URL) return; + try { + const blocks = outdatedVenues.flatMap(v => { + const outdatedTiles = v.tiles.filter(t => t.status === 'outdated'); + return [ + { type: 'section', text: { type: 'mrkdwn', text: `*<${v.url}|${v.name}>* — ${outdatedTiles.length} outdated tile(s)` } }, + { type: 'section', text: { type: 'mrkdwn', text: outdatedTiles.map(t => `• ${t.artist} — ~${t.dateStr}~`).join('\n') } }, + ]; + }); + + await axios.post(process.env.SLACK_WEBHOOK_URL, { + text: `:rotating_light: ${outdatedVenues.length} venue(s) have outdated homepage tiles`, + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'Outdated Homepage Carousel Tiles' } }, + ...blocks, + ], + }); + console.log('Slack notification sent.'); + } catch (err) { + console.error('Slack notification failed:', err.message); + } +} + +async function sendWebhookNotification(outdatedVenues) { + if (!process.env.WEBHOOK_URL) return; + try { + await axios.post(process.env.WEBHOOK_URL, { + event: 'outdated_tiles_detected', + timestamp: new Date().toISOString(), + venues: outdatedVenues.map(v => ({ + name: v.name, + url: v.url, + outdatedTiles: v.tiles.filter(t => t.status === 'outdated'), + })), + }); + console.log('Webhook notification sent.'); + } catch (err) { + console.error('Webhook notification failed:', err.message); + } +} + +// ─── SSE Broadcast ──────────────────────────────────────────────────────────── + +function broadcastSSE(payload) { + const msg = `data: ${JSON.stringify(payload)}\n\n`; + sseClients.forEach(client => { + try { client.res.write(msg); } catch (_) {} + }); +} + +// ─── API Routes ─────────────────────────────────────────────────────────────── + +app.get('/api/status', (req, res) => { + res.json({ venues: venueData, lastChecked, isScraping }); +}); + +app.post('/api/refresh', async (req, res) => { + res.json({ message: 'Scrape started' }); + scrapeAll(); +}); + +// Server-Sent Events for real-time dashboard updates +app.get('/api/events', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const client = { id: Date.now(), res }; + sseClients.push(client); + + // Send current state immediately on connect + res.write(`data: ${JSON.stringify({ type: 'update', data: venueData, lastChecked, isScraping })}\n\n`); + + // Keep-alive ping every 30 seconds + const ping = setInterval(() => { + try { res.write(': ping\n\n'); } catch (_) { clearInterval(ping); } + }, 30000); + + req.on('close', () => { + clearInterval(ping); + const idx = sseClients.indexOf(client); + if (idx !== -1) sseClients.splice(idx, 1); + }); +}); + +// ─── Start ──────────────────────────────────────────────────────────────────── + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Venue Carousel Tracker running at http://localhost:${PORT}`); + scrapeAll(); +}); + +// Run every hour at :00 +cron.schedule('0 * * * *', () => { + console.log('Scheduled hourly scrape triggered.'); + scrapeAll(); +}); From 9a28d10fa6a375e966848e0f0305df75a50bceee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 14:46:09 +0000 Subject: [PATCH 02/15] Convert to single self-contained HTML web app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Node.js server with a standalone index.html that works by opening directly in any browser — no install or server required. - Uses allorigins.win as a CORS proxy to fetch venue pages client-side - DOMParser replaces cheerio for HTML parsing (same selectors) - Skeleton cards render immediately while venues load in batches of 4 - Browser Notifications API for desktop alerts - Slack and generic webhook support via settings panel (localStorage) - Configurable warning threshold (default 7 days) - Auto-refreshes every hour via setTimeout https://claude.ai/code/session_01JoKM72wEiTc7vswPJGxsSM --- README.md | 76 ++---- index.html | 785 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 814 insertions(+), 47 deletions(-) create mode 100644 index.html diff --git a/README.md b/README.md index a82b863..2458caa 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,44 @@ # Venue Carousel Tracker -A web dashboard that monitors homepage carousel tiles across all O2 Academy venues and Edinburgh Corn Exchange, alerting the team when tiles are outdated and need replacing. +A single HTML file that monitors homepage carousel tiles across all 20 O2 Academy venues and Edinburgh Corn Exchange, alerting the team when tiles are outdated and need replacing. -## What It Does +## Usage -- Scrapes the homepage of all 20 venues every hour -- Detects carousel tiles where the event date has **already passed** (outdated) or is **within 7 days** (expiring soon) -- Displays a live dashboard with colour-coded status for each venue and tile -- Sends alerts via **email**, **Slack**, and/or a **generic webhook** when new outdated tiles are found -- Shows **browser desktop notifications** when the dashboard is open +**Just open `index.html` in any browser.** No server, no install, no dependencies. -## Setup +The page will immediately start scanning all 20 venues and update in real time as results come in. It re-checks automatically every hour. -### 1. Install dependencies +## What It Does -```bash -npm install -``` +- Fetches each venue homepage via a public CORS proxy (allorigins.win) +- Detects carousel tiles where the event date has **already passed** (outdated) or is within the warning threshold (default: **7 days**) +- Displays a colour-coded dashboard for all venues and their tiles +- Sends **browser desktop notifications** when tiles go outdated (click Enable Alerts) +- Posts to **Slack** and/or a **generic webhook** (configurable in Settings) -### 2. Configure notifications (optional) +## Status Indicators -```bash -cp .env.example .env -``` +| Colour | Meaning | +|---|---| +| 🔴 Red — Outdated | Event date has passed — tile needs replacing immediately | +| 🟠 Orange — Expiring Soon | Event is within the warning threshold (default 7 days) | +| 🟢 Green — OK | Event date is comfortably in the future | -Edit `.env` with your SMTP / Slack / webhook details. All notification channels are optional — leave them blank to disable. +## Notifications -### 3. Start the server +Click **Settings** (top-right) to configure: -```bash -npm start -``` +| Channel | What you need | +|---|---| +| Browser alerts | Click "Enable Alerts" — shows desktop notifications | +| Slack | An Incoming Webhook URL from api.slack.com/messaging/webhooks | +| Generic webhook | Any HTTPS endpoint that accepts a JSON POST | -Open **http://localhost:3000** in your browser. +Settings are saved in your browser's localStorage. ## Venues Tracked -| Venue | URL | +| Venue | Site | |---|---| | O2 Academy Birmingham | academymusicgroup.com/o2academybirmingham | | O2 Academy Bournemouth | academymusicgroup.com/o2academybournemouth | @@ -59,28 +61,8 @@ Open **http://localhost:3000** in your browser. | O2 Victoria Warehouse Manchester | academymusicgroup.com/o2victoriawarehousemanchester | | Edinburgh Corn Exchange | edinburghcornexchange.co.uk | -## Status Indicators - -| Colour | Meaning | -|---|---| -| 🔴 Red | Event date has passed — tile needs replacing immediately | -| 🟠 Orange | Event is within 7 days — tile will need replacing soon | -| 🟢 Green | Event is more than 7 days away — tile is fine | - -## API - -| Endpoint | Description | -|---|---| -| `GET /api/status` | Returns current venue data and last checked time | -| `POST /api/refresh` | Triggers an immediate scrape of all venues | -| `GET /api/events` | Server-Sent Events stream for real-time dashboard updates | - -## Notifications - -When a venue's carousel tiles become outdated, the server automatically sends alerts via any configured channel: - -- **Email** — uses nodemailer with any SMTP provider (Gmail, SendGrid, etc.) -- **Slack** — via Incoming Webhook -- **Generic Webhook** — HTTP POST with JSON payload +## Notes -Alerts are only sent when a venue *newly* becomes outdated (not on every hourly check). +- Pages are fetched via [allorigins.win](https://allorigins.win), a free public CORS proxy. Occasional fetch errors are normal — use Refresh to retry. +- Browser notifications and webhook alerts only fire when a venue *newly* becomes outdated, not on every hourly check. +- The warning threshold (default 7 days) can be changed in Settings. diff --git a/index.html b/index.html new file mode 100644 index 0000000..ead1f07 --- /dev/null +++ b/index.html @@ -0,0 +1,785 @@ + + + + + + Venue Carousel Tracker + + + + + +
+
+

Venue Carousel Tracker

+

Homepage tile monitor · 20 venues · auto-refreshes hourly

+
+
+
+ Last checked + +
+ + + +
+
+ + +
+
Outdated
+
Expiring Soon
+
Up to Date
+
Venues
+
+ + + + +

Pages are fetched via a public CORS proxy. If a venue shows an error, try refreshing — transient proxy failures are normal.

+ + +
+ + + + + +
+ + +
+

Starting up…

+
+ + + + + +
+ Auto-refreshes every hour + · + + · + +
+ + + + From 9206860b2fc2cbea89cc35f325055ffabf4cabfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 14:51:46 +0000 Subject: [PATCH 03/15] Add multi-proxy fallback to fix fetch failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single allorigins.win proxy with an ordered fallback chain: corsproxy.io → allorigins (JSON) → codetabs. Each venue request tries all three in sequence before failing. Added AbortController timeout (18 s per attempt) and a custom proxy URL option in Settings. https://claude.ai/code/session_01JoKM72wEiTc7vswPJGxsSM --- index.html | 95 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index ead1f07..94ece00 100644 --- a/index.html +++ b/index.html @@ -312,7 +312,7 @@

Venue Carousel Tracker

-

Pages are fetched via a public CORS proxy. If a venue shows an error, try refreshing — transient proxy failures are normal.

+

Pages are fetched via public CORS proxies (corsproxy.io → allorigins → codetabs). Each venue tries all three in order — errors mean all proxies failed for that site. Try Refresh or add a custom proxy in Settings.

@@ -349,6 +349,12 @@

Settings

Receives a JSON POST: { event, timestamp, venues[] }

+
+ + +

Must append the encoded target URL. Leave blank to use the built-in proxies (corsproxy.io → allorigins → codetabs).

+
+
@@ -405,29 +411,53 @@

Settings

let notifGranted = Notification.permission === 'granted'; let alertedSet = new Set(); // venues already notified this session -const PROXY = 'https://api.allorigins.win/raw?url='; +// Ordered list of CORS proxies — tried in sequence until one succeeds +const PROXY_LIST = [ + { + name: 'corsproxy.io', + wrap: url => `https://corsproxy.io/?${encodeURIComponent(url)}`, + unwrap: res => res.text(), + }, + { + name: 'allorigins', + wrap: url => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`, + unwrap: res => res.json().then(d => { if (!d || !d.contents) throw new Error('empty'); return d.contents; }), + }, + { + name: 'codetabs', + wrap: url => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`, + unwrap: res => res.text(), + }, +]; // ── Settings ──────────────────────────────────────────────────────────────── function loadSettings() { return { - slackUrl: localStorage.getItem('slackUrl') || '', - webhookUrl: localStorage.getItem('webhookUrl') || '', - warnDays: parseInt(localStorage.getItem('warnDays') || '7'), + slackUrl: localStorage.getItem('slackUrl') || '', + webhookUrl: localStorage.getItem('webhookUrl') || '', + warnDays: parseInt(localStorage.getItem('warnDays') || '7'), + customProxy: localStorage.getItem('customProxy') || '', }; } +function getProxies() { + const custom = (localStorage.getItem('customProxy') || '').trim(); + if (!custom) return PROXY_LIST; + return [ + { name: 'custom', wrap: url => `${custom}${encodeURIComponent(url)}`, unwrap: res => res.text() }, + ...PROXY_LIST, + ]; +} + function saveSettings() { - localStorage.setItem('slackUrl', document.getElementById('slack-url').value.trim()); - localStorage.setItem('webhookUrl', document.getElementById('webhook-url').value.trim()); + localStorage.setItem('slackUrl', document.getElementById('slack-url').value.trim()); + localStorage.setItem('webhookUrl', document.getElementById('webhook-url').value.trim()); + localStorage.setItem('customProxy', document.getElementById('custom-proxy').value.trim()); const d = parseInt(document.getElementById('warn-days').value) || 7; localStorage.setItem('warnDays', d); closeSettings(); - // Re-render with new threshold - if (Object.keys(venueData).length) { - recalcStatus(); - renderAll(); - } + if (Object.keys(venueData).length) { recalcStatus(); renderAll(); } } // ── Date Parsing ───────────────────────────────────────────────────────────── @@ -475,12 +505,34 @@

Settings

// ── Scraping ───────────────────────────────────────────────────────────────── +async function fetchWithTimeout(url, ms) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + return await fetch(url, { cache: 'no-store', signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } +} + async function fetchHTML(url) { - const res = await fetch(PROXY + encodeURIComponent(url), { cache: 'no-store' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const text = await res.text(); - if (!text || text.length < 500) throw new Error('Empty or truncated response'); - return text; + const proxies = getProxies(); + const errors = []; + + for (const proxy of proxies) { + try { + const res = await fetchWithTimeout(proxy.wrap(url), 18000); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await proxy.unwrap(res); + if (!text || text.length < 500) throw new Error('Empty response'); + return text; + } catch (err) { + const msg = err.name === 'AbortError' ? 'timeout' : err.message; + errors.push(`${proxy.name}: ${msg}`); + } + } + + throw new Error(errors.join(' · ')); } function parseAMG(html) { @@ -767,9 +819,10 @@

Settings

function openSettings() { const s = loadSettings(); - document.getElementById('slack-url').value = s.slackUrl; - document.getElementById('webhook-url').value = s.webhookUrl; - document.getElementById('warn-days').value = s.warnDays; + document.getElementById('slack-url').value = s.slackUrl; + document.getElementById('webhook-url').value = s.webhookUrl; + document.getElementById('custom-proxy').value = s.customProxy; + document.getElementById('warn-days').value = s.warnDays; document.getElementById('overlay').classList.remove('hidden'); } function closeSettings() { document.getElementById('overlay').classList.add('hidden'); } @@ -777,7 +830,7 @@

Settings

// ── Init ─────────────────────────────────────────────────────────────────── -document.getElementById('proxy-label').textContent = 'Proxy: allorigins.win'; +document.getElementById('proxy-label').textContent = 'Proxies: corsproxy.io, allorigins, codetabs'; updateNotifBtn(); startScrape(); From 201ae79f2eabcdb7b43120139f86fd9ab8201448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 15:11:54 +0000 Subject: [PATCH 04/15] Fix AMG parser to capture content-list events, not just carousel parseAMG now scans both carousel-slide elements AND the module-content-list-large/medium/small sections that carry the full event listing (16 events on O2 Ritz vs 6 from the carousel alone). Items with no parseable date are skipped to filter out navigation links that share the same selector. Deduplication prevents double-counting events that appear in both sections. https://claude.ai/code/session_01JoKM72wEiTc7vswPJGxsSM --- index.html | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 94ece00..2aaecad 100644 --- a/index.html +++ b/index.html @@ -536,17 +536,36 @@

Settings

} function parseAMG(html) { - const doc = new DOMParser().parseFromString(html, 'text/html'); - const tiles = []; const seen = new Set(); - doc.querySelectorAll('[data-testid="carousel-slide"]').forEach(slide => { - const artist = (slide.querySelector('h2,h3') || {}).textContent?.trim() || ''; - const dateStr = (slide.querySelector('p') || {}).textContent?.trim() || ''; + const doc = new DOMParser().parseFromString(html, 'text/html'); + const tiles = []; + const seen = new Set(); + + function addTile(artist, dateStr) { if (!artist) return; - const key = `${artist}|${dateStr}`; - if (seen.has(key)) return; seen.add(key); + const key = artist + '|' + dateStr; + if (seen.has(key)) return; + seen.add(key); const date = parseDate(dateStr); tiles.push({ artist, dateStr, date: date?.toISOString() || null, status: tileStatus(date) }); + } + + // 1. Featured carousel at the top + doc.querySelectorAll('[data-testid="carousel-slide"]').forEach(slide => { + addTile( + (slide.querySelector('h2,h3') || {}).textContent?.trim() || '', + (slide.querySelector('p') || {}).textContent?.trim() || '', + ); }); + + // 2. Content-list sections (large / medium / small) — same structure as ECE. + // Require a parseable date to skip navigation/info links that share the selector. + doc.querySelectorAll('[data-testid="module-content-list-item"]').forEach(item => { + const artist = item.querySelector('[data-testid="module-content-list-item-title"]')?.textContent?.trim() || ''; + const dateStr = item.querySelector('[data-testid="module-content-list-item-subtitle"]')?.textContent?.trim() || ''; + if (!parseDate(dateStr)) return; + addTile(artist, dateStr); + }); + return tiles; } From a8dcc7e2b0b291c9cd5e7d15f139b05484417cff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 15:23:51 +0000 Subject: [PATCH 05/15] Add 8 new features: events view, search, change detection, export, and more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features added: - Sort tiles by date ascending within every venue card - Per-venue ↺ refresh button (event delegation, skips full scan) - Cross-venue Events tab — flat date-sorted table of all events across all venues, filtered by the same status pills and search box - Artist/venue search — live-filters both card and events views - Configurable auto-refresh interval (off / 15 min / 30 min / 1h / 2h) replacing the hardcoded 1-hour timer; footer label updates dynamically - Change detection — compares each scan against the previous (persisted in localStorage); NEW badge on new tiles, REMOVED tiles shown struck-through, changes summary in card footer - Relative scan-age per card ("Checked 4m ago"), turns amber after 3h and refreshes every 30s without re-rendering - Export CSV button (header + footer) — downloads all events with venue, artist, date, status, days-until, and is-new columns - Total Events counter added to summary bar - Settings modal now includes auto-refresh interval select https://claude.ai/code/session_01JoKM72wEiTc7vswPJGxsSM --- index.html | 610 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 486 insertions(+), 124 deletions(-) diff --git a/index.html b/index.html index 2aaecad..afbb980 100644 --- a/index.html +++ b/index.html @@ -17,6 +17,9 @@ --green: #16a34a; --green-light: #f0fdf4; --green-border: #86efac; + --blue: #2563eb; + --blue-light: #eff6ff; + --blue-border: #93c5fd; --gray: #6b7280; --gray-light: #f9fafb; --gray-border: #e5e7eb; @@ -102,8 +105,14 @@ .banner.error { background: #fef2f2; border-bottom: 1px solid var(--red-border); color: var(--red); } .hidden { display: none !important; } - /* ── Filter Bar ── */ - .filters { display: flex; gap: 0.45rem; padding: 1rem 1.75rem 0; flex-wrap: wrap; } + /* ── Controls Bar (filters + search + view tabs) ── */ + .controls { + display: flex; align-items: center; gap: 0.65rem; + padding: 0.75rem 1.75rem; + background: var(--surface); border-bottom: 1px solid var(--border); + flex-wrap: wrap; + } + .filt-group { display: flex; gap: 0.45rem; flex-wrap: wrap; flex: 1; } .filt { padding: 0.3rem 0.85rem; border-radius: 20px; border: 1.5px solid var(--border); background: var(--surface); font-size: 0.78rem; font-weight: 600; @@ -112,7 +121,27 @@ .filt:hover { border-color: #94a3b8; color: var(--text); } .filt.active { background: var(--navy); color: #fff; border-color: var(--navy); } - /* ── Grid ── */ + .search-wrap { position: relative; min-width: 180px; max-width: 260px; } + .search-wrap svg { position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; } + .search-input { + width: 100%; padding: 0.32rem 0.75rem 0.32rem 2rem; + border: 1.5px solid var(--border); border-radius: 20px; + font-size: 0.8rem; outline: none; transition: border-color .15s; + background: var(--bg); color: var(--text); + } + .search-input:focus { border-color: #3b82f6; background: #fff; } + + .view-tabs { display: flex; border: 1.5px solid var(--border); border-radius: 8px; overflow: hidden; flex-shrink: 0; } + .view-tab { + padding: 0.28rem 0.85rem; font-size: 0.78rem; font-weight: 600; + cursor: pointer; border: none; background: none; color: var(--text-muted); + transition: all .15s; + } + .view-tab:hover { color: var(--text); background: var(--bg); } + .view-tab.active { background: var(--navy); color: #fff; } + .view-tab + .view-tab { border-left: 1.5px solid var(--border); } + + /* ── Grid (card view) ── */ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); @@ -139,6 +168,15 @@ text-decoration: none; line-height: 1.3; } .card-head a:hover { text-decoration: underline; color: #2563eb; } + .card-head-right { display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0; } + + .card-refresh-btn { + width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--border); + background: var(--bg); cursor: pointer; display: flex; align-items: center; + justify-content: center; color: var(--text-muted); transition: all .15s; flex-shrink: 0; + } + .card-refresh-btn:hover { background: #e2e8f0; color: var(--text); } + .card-refresh-btn:disabled { opacity: .4; cursor: not-allowed; } .badge { flex-shrink: 0; font-size: 0.65rem; font-weight: 700; @@ -150,6 +188,7 @@ .badge.ok { background: var(--green-light); color: var(--green); border: 1px solid var(--green-border); } .badge.error { background: var(--gray-light); color: var(--gray); border: 1px solid var(--gray-border); } .badge.loading { background: #eff6ff; color: #3b82f6; border: 1px solid #bfdbfe; } + .badge.unknown { background: var(--gray-light); color: var(--gray); border: 1px solid var(--gray-border); } .tiles { padding: 0 1rem 0.9rem; display: flex; flex-direction: column; gap: 0.35rem; flex: 1; } @@ -161,17 +200,22 @@ .tile.outdated { background: var(--red-light); border-color: var(--red-border); } .tile.expiring_soon { background: var(--orange-light); border-color: var(--orange-border); } .tile.ok { background: var(--green-light); border-color: var(--green-border); } + .tile.is-new { background: var(--blue-light); border-color: var(--blue-border); } + .tile.removed { background: #f8fafc; border-color: var(--border); opacity: 0.6; } .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--gray); } .tile.outdated .dot { background: var(--red); } .tile.expiring_soon .dot { background: var(--orange); } .tile.ok .dot { background: var(--green); } + .tile.is-new .dot { background: var(--blue); } + .tile.removed .dot { background: #9ca3af; } .tile-info { flex: 1; min-width: 0; } .tile-artist { font-size: 0.78rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tile-date { font-size: 0.7rem; color: var(--text-muted); margin-top: 1px; } .tile.outdated .tile-date { color: var(--red); font-weight: 600; } .tile.expiring_soon .tile-date { color: var(--orange); font-weight: 600; } + .removed-artist { text-decoration: line-through; color: var(--text-muted) !important; } .tile-tag { font-size: 0.65rem; font-weight: 700; flex-shrink: 0; @@ -179,11 +223,20 @@ } .tile.outdated .tile-tag { background: var(--red); color: #fff; } .tile.expiring_soon .tile-tag { background: var(--orange); color: #fff; } + .new-tag { background: var(--blue) !important; color: #fff !important; } + .removed-tag { background: #9ca3af !important; color: #fff !important; } .card-foot { padding: 0.45rem 1rem 0.7rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--text-muted); + display: flex; align-items: center; flex-wrap: wrap; gap: 0.2rem; } + .changes-note { + font-size: 0.67rem; font-weight: 700; padding: 0.1rem 0.4rem; + border-radius: 4px; background: var(--blue-light); color: var(--blue); margin-left: 0.15rem; + } + .scan-age { transition: color .3s; } + .card-err { font-size: 0.75rem; color: var(--gray); padding: 0.5rem 1rem 0.9rem; } /* skeleton tiles while loading */ @@ -211,6 +264,41 @@ .spin.lg { width: 32px; height: 32px; border-width: 3.5px; } @keyframes rot { to { transform: rotate(360deg); } } + /* ── Events Table View ── */ + #event-list { padding: 1.1rem 1.75rem 2rem; flex: 1; overflow-x: auto; } + .ev-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } + .ev-table th { + text-align: left; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; + letter-spacing: .05em; color: var(--text-muted); + padding: 0.7rem 0.9rem; border-bottom: 2px solid var(--border); + background: var(--gray-light); + } + .ev-row { border-bottom: 1px solid var(--border); transition: background .1s; } + .ev-row:last-child { border-bottom: none; } + .ev-row:hover { background: #f8fafc; } + .ev-row td { padding: 0.55rem 0.9rem; vertical-align: middle; } + .ev-row.outdated td:first-child { border-left: 3px solid var(--red); } + .ev-row.expiring_soon td:first-child { border-left: 3px solid var(--orange); } + .ev-row.ok td:first-child { border-left: 3px solid var(--green); } + .ev-row.is-new td:first-child { border-left: 3px solid var(--blue); } + .ev-date { font-weight: 500; white-space: nowrap; } + .ev-rel { + font-size: 0.67rem; font-weight: 700; margin-left: 0.4rem; + padding: 0.08rem 0.35rem; border-radius: 4px; + background: var(--gray-light); color: var(--text-muted); white-space: nowrap; + } + .ev-row.outdated .ev-rel { background: var(--red-light); color: var(--red); } + .ev-row.expiring_soon .ev-rel { background: var(--orange-light); color: var(--orange); } + .ev-artist { font-weight: 600; } + .ev-venue a { color: var(--blue); text-decoration: none; font-size: 0.78rem; } + .ev-venue a:hover { text-decoration: underline; } + .new-badge { + font-size: 0.6rem; font-weight: 700; padding: 0.08rem 0.32rem; + border-radius: 3px; background: var(--blue); color: #fff; + margin-left: 0.4rem; vertical-align: middle; + } + #event-list .placeholder { grid-column: unset; } + /* ── Settings Modal ── */ .overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); @@ -221,18 +309,21 @@ background: var(--surface); border-radius: var(--radius); padding: 1.75rem; max-width: 540px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,.25); + max-height: 90vh; overflow-y: auto; } .modal h2 { font-size: 1.1rem; margin-bottom: 0.4rem; } .modal-desc { font-size: 0.82rem; color: var(--text-muted); margin-bottom: 1.25rem; line-height: 1.5; } .field { margin-bottom: 1rem; } .field label { display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.35rem; } - .field input { - width: 100%; padding: 0.5rem 0.75rem; border: 1.5px solid var(--border); + .field input, .field select { + padding: 0.5rem 0.75rem; border: 1.5px solid var(--border); border-radius: 8px; font-size: 0.85rem; color: var(--text); - outline: none; transition: border-color .15s; + outline: none; transition: border-color .15s; background: var(--surface); } - .field input:focus { border-color: #3b82f6; } + .field input { width: 100%; } + .field select { cursor: pointer; } + .field input:focus, .field select:focus { border-color: #3b82f6; } .field .hint { font-size: 0.72rem; color: var(--text-muted); margin-top: 0.3rem; } .modal-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 1.25rem; } @@ -247,7 +338,7 @@ .footer { padding: 0.65rem 1.75rem; border-top: 1px solid var(--border); background: var(--surface); display: flex; align-items: center; - gap: 0.65rem; font-size: 0.75rem; color: var(--text-muted); + gap: 0.65rem; font-size: 0.75rem; color: var(--text-muted); flex-wrap: wrap; } .footer button { background: none; border: none; color: #3b82f6; font-size: 0.75rem; cursor: pointer; text-decoration: underline; } .footer button:hover { color: #2563eb; } @@ -261,9 +352,11 @@ @media (max-width: 580px) { .header { padding: 0.9rem 1rem; } .summary { padding: 0.75rem 1rem; } - .filters { padding: 0.75rem 1rem 0; } + .controls { padding: 0.65rem 1rem; } .grid { padding: 1rem; grid-template-columns: 1fr; } + #event-list { padding: 1rem; } .header-right { width: 100%; justify-content: flex-end; } + .search-wrap { max-width: 100%; width: 100%; } } @@ -273,7 +366,7 @@

Venue Carousel Tracker

-

Homepage tile monitor · 20 venues · auto-refreshes hourly

+

Homepage tile monitor · 20 venues

@@ -284,6 +377,10 @@

Venue Carousel Tracker

Refresh +
@@ -314,33 +412,64 @@

Venue Carousel Tracker

Pages are fetched via public CORS proxies (corsproxy.io → allorigins → codetabs). Each venue tries all three in order — errors mean all proxies failed for that site. Try Refresh or add a custom proxy in Settings.

- -
- - - - - + +
+
+ + + + + +
+
+ + +
+
+ + +
- +

Starting up…

+ + +