From f1acd53a9c9cf37741b8163a1d6339303ecac4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Sivertsson?= Date: Mon, 12 Oct 2026 16:06:40 +0200 Subject: [PATCH] SAS bonus watch userscript Tampermonkey/Violentmonkey userscript that watches SAS EuroBonus award availability from a sas.se tab and notifies via ntfy, Discord, Slack, webhook or desktop when matching seats appear. Moved here from sas-bonus-finder; update URLs point at this public repo. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + README.md | 133 ++++++ sas-bonus-watch.test.cjs | 211 +++++++++ sas-bonus-watch.user.js | 906 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 1254 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 sas-bonus-watch.test.cjs create mode 100644 sas-bonus-watch.user.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bb2711 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3402f0 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# SAS bonus watch + +A Tampermonkey / Violentmonkey userscript that watches SAS EuroBonus award availability and notifies you when +seats you care about appear. It runs inside your own browser, so requests to sas.se are ordinary same-origin +browser requests and it keeps working behind sas.se's Cloudflare protection. + +- Any number of **watches**: origins, destinations, one travel window, cabins (Go / Plus / Business), seats, max stops +- Finds **outbound and return** flights inside the window; optionally only alerts when both legs exist, with min/max nights +- **Gentle on sas.se**: about one request a minute, a full sweep at most every 15 minutes, backs off when refused +- Alerts via **ntfy** (phone push), **Discord**, **Slack**, any **webhook**, and desktop notifications +- Dashboard shows what is bookable now **and what you missed**: seat drops and sold-out flights + +## Install + +1. Install [Tampermonkey](https://www.tampermonkey.net/) (Firefox, Chrome, Edge, Safari) or + [Violentmonkey](https://violentmonkey.github.io/). +2. Open the raw script and let the extension install it: + **** + (or: Tampermonkey → *Create a new script* → paste the file → save). +3. Open **** and keep that tab open. It turns into the dashboard. + Pin the tab so the browser doesn't unload it. + +> **Chrome / Edge:** userscripts need *Developer mode* enabled on `chrome://extensions` (or "Allow user scripts" +> in the Tampermonkey extension details) since Manifest V3. + +## Set up + +In the **Config** section of the dashboard: + +1. **Watches** – one row per search: + + | Column | Meaning | + |---|---| + | On | Pause a watch without deleting it | + | From / To | Airport codes, comma-separated (`ARN, CPH`). Every From × To pair is checked | + | Earliest / Latest | Travel window. Outbound **and** return flights departing in it are found | + | Cabins | Go (AG), Plus (AP), Business (AB) | + | Seats | Seats needed **on the same flight** | + | Stops | Any, direct only, or at most 1 / 2 stops | + | Both legs | Only alert when an outbound has a later return in the window (default on). Off = alert for either direction on its own | + | Nights | Optional min / max stay, measured between the two departure dates (turns on Both legs) | + +2. **Notifications** – add one or more targets: + + | Type | URL | + |---|---| + | `ntfy` | `https://ntfy.sh/`. Install the ntfy app and subscribe to the same topic. Priority sets how loud it is | + | `discord` | A channel webhook URL | + | `slack` | An incoming-webhook URL | + | `webhook` | Anything; receives `POST` JSON `{title, body, watch, matches: [...]}` | + + The first time the script posts to a host, Tampermonkey asks you to allow it — choose *Always allow domain*. + +3. Press **Save & restart**, then **Test notification**. +4. Optional: **Baseline (no alerts)** records what is available right now, so you are only told about new seats. + +### JSON export / import / reset + +Under the tables, **JSON – export / import / reset** holds the same config as JSON: +*Download .json* for a backup, *Load file…* or paste + *Apply JSON to tables* to restore, +*Reset to example* to start over. Nothing takes effect until you press **Save & restart**. + +## Dashboard + +- **Current matches** per watch: + - `Go ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen + - red, struck through, *gone 12 okt 13:42* – sold out (or below your seat count); kept for two weeks or until departure + - grey, *no matching return* – still has seats, but its return disappeared (with Both legs) +- **Recent changes**: `+` new / back, `▲` more seats, `▼` fewer seats, `−` gone, `○` lost its return +- **Log**: every request and sweep + +You are notified only when seats are **new or come back**. Drops and sold-out flights are shown, not pushed. + +## How it polls + +1. **Calendar** – `/bff/award-finder/destinations/v1?origin=ARN&destinations=HND&passengers=N`: one request per + route pair returns every date with seats **in both directions**. With `passengers=N` it only counts flights + that have N free seats. +2. **Details** – `/bff/award-finder/routes/v1?origin=…&destination=…&departureDate=…`: only for dates in your + window that have seats in your cabins, and only when that date's calendar entry changed (or every 6 h). + +Requests are at least 40 s apart (+0–20 s random) and a sweep starts at most every 15 minutes; both are adjustable +under *Request pacing* (minimum 20 s). If sas.se refuses requests, the script backs off exponentially (up to an hour), +the badge turns red (*blocked – reload this tab*) and you get a notification. Reload the tab and complete the check +if sas.se shows one. + +Only one tab polls at a time, so opening the dashboard twice does not double the rate. + +## Limitations + +- Runs only while the browser is open and the computer awake. +- Firefox / Chrome may unload background tabs under memory pressure; pinning the tab usually prevents it. +- Config, matches and history live in the userscript manager's storage on that browser only. Use *Download .json* + to move the config elsewhere. + +## Auto-update + +The script header points Tampermonkey at this repo: + +``` +// @version 1.1.1 +// @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js +// @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js +``` + +Tampermonkey periodically downloads `@updateURL` (default once a day; *Dashboard → Settings → Script Update*, or +*Check for userscript updates* in the extension menu to force it) and installs the new file **only if its +`@version` is higher**. Settings and history are kept across updates. + +Requirements: + +- **The raw URL must be readable without logging in.** Tampermonkey cannot send Gitea credentials, so the repo + (or at least this file) must be public. Check by opening the raw URL in a private window. + If the repo has to stay private, publish just the `.user.js` somewhere public (a public mirror repo, or a public + Gitea release asset) and point both URLs there. +- The script must be installed from that URL (or have the header above) for updates to be picked up. +- `@name` and `@namespace` must never change, or Tampermonkey treats it as a different script and the new copy + starts with an empty config. + +### Releasing a change + +1. Edit `sas-bonus-watch.user.js`. +2. Run the tests: `node --test` (Node 20+). +3. **Bump `@version`** (e.g. 1.1.1 → 1.1.2). Without this, nobody gets the update. +4. Commit and push to `main`. + +## Development + +- `sas-bonus-watch.user.js` – the script. The pure logic (config validation, parsing, matching, state diffing) + is exported to Node when loaded with `require`, the browser part only runs in the userscript manager. +- `sas-bonus-watch.test.cjs` – tests for the logic using response shapes captured from sas.se. Run `node --test`. + +Nothing is sent anywhere except sas.se and the notification targets you configure. diff --git a/sas-bonus-watch.test.cjs b/sas-bonus-watch.test.cjs new file mode 100644 index 0000000..4c39439 --- /dev/null +++ b/sas-bonus-watch.test.cjs @@ -0,0 +1,211 @@ +// node --test +const test = require('node:test'); +const assert = require('node:assert/strict'); +const core = require('./sas-bonus-watch.user.js'); + +// Shapes captured from sas.se on 2026-10-12 +const ROUTES_ARN_HND_1209 = [ + { flightId: 'SK1415-20261209-ARN-CPH', arrivalDate: '2026-12-10', arrivalTime: '07:55', departureDate: '2026-12-09', + departureTime: '06:00', availability: { AG: 0, AP: 1, AB: 0 }, flyTime: 830, totalTime: 1075, noOfFlights: 2, haulType: 'LH' }, + { flightId: 'SK1417-20261209-ARN-CPH', arrivalDate: '2026-12-10', arrivalTime: '07:55', departureDate: '2026-12-09', + departureTime: '07:00', availability: { AG: 0, AP: 1, AB: 0 }, flyTime: 830, totalTime: 1015, noOfFlights: 2, haulType: 'LH' }, +]; + +const day = (date, seats) => ({ date, key: +date.replace(/-/g, '').slice(2), availableSeatsTotal: Object.values(seats).reduce((a, b) => a + b, 0), ...seats }); +const calendarPayload = (outbound, inbound) => [{ airportCode: 'HND', cityName: 'Tokyo', flightClasses: ['AG', 'AP'], + availability: { outbound: outbound.map(([d, s]) => day(d, s)), inbound: inbound.map(([d, s]) => day(d, s)) } }]; + +class Blocked extends Error {} + +function setup(watches, responses, { prefilter = true, now } = {}) { + const cfg = core.validateConfig({ settings: { prefilter }, notify: [], watches }); + const calls = []; + const sent = []; + const answer = (key) => { + calls.push(key); + const r = responses[key]; + if (r instanceof Error) throw r; + return r || []; + }; + const api = { + calendar: async (o, d, direct, pax) => core.parseCalendar(answer(`cal ${o} ${d}`), d), + flights: async (o, d, dayIso) => answer(`routes ${o} ${d} ${dayIso}`).map(core.parseFlight), + }; + const state = {}; + const w = core.createWatcher({ cfg, api, state, save() {}, log() {}, ...(now && { now }), + notify: async (title, body) => { sent.push({ title, body }); return true; } }); + return { w, calls, sent, state }; +} + +// one-way style by default so single-leg behaviour is easy to assert +const watch = (extra = {}) => ({ name: 'tokyo', origins: ['ARN'], destinations: ['HND'], + from: '2026-12-01', to: '2026-12-20', cabins: ['AP'], require_both_legs: false, ...extra }); + +const baseResponses = () => ({ + 'cal ARN HND': calendarPayload([['2026-12-09', { AP: 1 }], ['2026-12-08', { AG: 5 }]], [['2027-01-05', { AG: 4 }]]), + 'routes ARN HND 2026-12-09': ROUTES_ARN_HND_1209, +}); + +test('new seats notify once, unchanged calendar uses cache', async () => { + const { w, calls, sent } = setup([watch()], baseResponses()); + assert.equal(await w.runCycle(), 2); + assert.equal(sent.length, 1); + assert.match(sent[0].body, /SAS Plus ×1/); + assert.match(sent[0].body, /OUT ARN→HND Wed 09 Dec 06:00→Thu 10 Dec 07:55, 1 stop/); + assert.deepEqual(calls, ['cal ARN HND', 'routes ARN HND 2026-12-09']); // 12-08 has only AG -> never fetched + + calls.length = 0; + assert.equal(await w.runCycle(), 0); + assert.equal(sent.length, 1); + assert.deepEqual(calls, ['cal ARN HND']); +}); + +test('disappear then reappear notifies again', async () => { + const r = baseResponses(); + const { w, sent } = setup([watch()], r); + await w.runCycle(); + const full = r['cal ARN HND']; + r['cal ARN HND'] = calendarPayload([], []); + await w.runCycle(); + r['cal ARN HND'] = full; + await w.runCycle(); + assert.equal(sent.length, 2); +}); + +test('min_seats, cabins and max_stops filter', async () => { + assert.equal(await setup([watch({ min_seats: 2 })], baseResponses()).w.runCycle(), 0); + assert.equal(await setup([watch({ cabins: ['AB'] })], baseResponses()).w.runCycle(), 0); + assert.equal(await setup([watch({ max_stops: 0 })], baseResponses()).w.runCycle(), 0); +}); + +test('require_both_legs (the default) and max_nights within one travel window', async () => { + const trip = { from: '2026-12-01', to: '2027-01-10', require_both_legs: undefined }; + const r = baseResponses(); + assert.equal(await setup([watch(trip)], r).w.runCycle(), 0); // no AP seats home + + r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], [['2027-01-05', { AP: 2 }]]); + r['routes HND ARN 2027-01-05'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20270105-HND-CPH', + departureDate: '2027-01-05', arrivalDate: '2027-01-05', availability: { AP: 2 } }]; + const both = setup([watch(trip)], r); + assert.equal(await both.w.runCycle(), 3); + assert.match(both.sent[0].body, /RET HND→ARN/); + + assert.equal(await setup([watch({ ...trip, max_nights: 20 })], r).w.runCycle(), 0); // 27 nights +}); + +test('blocked request aborts without committing calendar or notifying', async () => { + const r = baseResponses(); + r['routes ARN HND 2026-12-09'] = new Blocked('HTTP 403'); + const { w, sent, state } = setup([watch()], r); + await assert.rejects(w.runCycle(), Blocked); + assert.deepEqual(state.calendar, {}); + assert.equal(sent.length, 0); +}); + +test('prefilter off fetches every day in both directions', async () => { + const { w, calls } = setup([watch({ from: '2026-12-08', to: '2026-12-09' })], baseResponses(), { prefilter: false }); + assert.equal(await w.runCycle(), 2); + assert.deepEqual(calls, ['routes ARN HND 2026-12-08', 'routes ARN HND 2026-12-09', + 'routes HND ARN 2026-12-08', 'routes HND ARN 2026-12-09']); +}); + +test('inbound flights in the window are found too', async () => { + const r = baseResponses(); + r['cal ARN HND'] = calendarPayload([], [['2026-12-12', { AP: 2 }]]); + r['routes HND ARN 2026-12-12'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20261212-HND-CPH', + departureDate: '2026-12-12', arrivalDate: '2026-12-12', availability: { AP: 2 } }]; + const { w, sent } = setup([watch()], r); + assert.equal(await w.runCycle(), 1); + assert.match(sent[0].body, /RET HND→ARN Sat 12 Dec/); +}); + +test('one calendar request serves both directions', async () => { + const a = watch({ name: 'a' }); + const b = watch({ name: 'b', origins: ['HND'], destinations: ['ARN'], from: '2027-01-01', to: '2027-01-10', cabins: ['AG'] }); + const { w, calls } = setup([a, b], baseResponses()); + await w.runCycle(); + assert.equal(calls.filter((c) => c.startsWith('cal')).length, 1); +}); + +test('config validation', () => { + assert.ok(core.validateConfig(core.DEFAULT_CONFIG)); + assert.throws(() => core.validateConfig({ watches: [{ origins: 'ARNX', destinations: ['HND'], from: '2026-12-01', to: '2026-11-01' }] }), + /"to" is before "from"[\s\S]*not a 3-letter airport code/); + assert.throws(() => core.validateConfig({ settings: { min_request_interval: 5 }, watches: [watch()] }), /at least 20 seconds/); + assert.throws(() => core.validateConfig({ watches: [watch({ min_nights: 9, max_nights: 5 })] }), /max nights is below min nights/); + + // table input: comma-separated airports, empty optional numbers, disabled rows dropped + const cfg = core.validateConfig({ watches: [ + watch({ origins: 'arn, cph', min_nights: '', max_stops: '' }), + watch({ name: 'off', enabled: false }), + ] }); + assert.deepEqual(cfg.watches.map((w) => w.name), ['tokyo']); + assert.deepEqual(cfg.watches[0].origins, ['ARN', 'CPH']); + assert.equal(cfg.watches[0].min_nights, null); + assert.equal(cfg.watches[0].max_stops, null); +}); + +test('old outbound/inbound configs are migrated to one window', () => { + const cfg = core.validateConfig({ watches: [{ name: 'old', origins: ['ARN'], destinations: ['HND'], + outbound: { from: '2026-12-15', to: '2026-12-23' }, inbound: { from: '2027-01-02', to: '2027-01-10' } }] }); + assert.deepEqual(cfg.watches[0].window, { from: '2026-12-15', to: '2027-01-10' }); + assert.equal(cfg.watches[0].require_both_legs, true); +}); + +const withSeats = (n) => ROUTES_ARN_HND_1209.map((f) => ({ ...f, availability: { AP: n } })); + +test('seat drops are tracked against the peak, gone matches stay listed in red', async () => { + const r = baseResponses(); + r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 20 }]], []); + r['routes ARN HND 2026-12-09'] = withSeats(10); + const { w, state, sent } = setup([watch()], r); + await w.runCycle(); + + r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 16 }]], []); // calendar changed -> details refetched + r['routes ARN HND 2026-12-09'] = withSeats(8); + await w.runCycle(); + const e = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP']; + assert.deepEqual([e.status, e.seats, e.peak], ['active', 8, 10]); + assert.equal(state.events[0].kind, 'down'); + assert.match(state.events[0].line, /SAS Plus ×10 → ×8/); + assert.equal(sent.length, 1); // a drop is shown, not alerted + + r['cal ARN HND'] = calendarPayload([], []); + await w.runCycle(); + const gone = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP']; + assert.deepEqual([gone.status, gone.seats, gone.peak], ['gone', 8, 10]); + assert.equal(state.events[0].kind, 'gone'); + + r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 4 }]], []); + r['routes ARN HND 2026-12-09'] = withSeats(2); + await w.runCycle(); + assert.equal(state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP'].status, 'active'); + assert.equal(state.events[0].kind, 'back'); + assert.equal(sent.length, 2); // back again -> alerted again +}); + +test('lost matches are forgotten after two weeks', async () => { + let t = Date.parse('2026-10-12T12:00:00Z'); + const r = baseResponses(); + const { w, state } = setup([watch()], r, { now: () => t }); + await w.runCycle(); + r['cal ARN HND'] = calendarPayload([], []); + await w.runCycle(); + assert.equal(Object.values(state.matches.tokyo).every((e) => e.status === 'gone'), true); + t += 15 * 86400000; + await w.runCycle(); + assert.deepEqual(state.matches.tokyo, {}); +}); + +test('a leg whose return disappeared is marked unpaired, not gone', async () => { + const r = baseResponses(); + r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], [['2026-12-20', { AP: 1 }]]); + r['routes HND ARN 2026-12-20'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20261220-HND-CPH', + departureDate: '2026-12-20', arrivalDate: '2026-12-20', availability: { AP: 1 } }]; + const { w, state } = setup([watch({ require_both_legs: true })], r); + assert.equal(await w.runCycle(), 3); + r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], []); + await w.runCycle(); + const st = Object.fromEntries(Object.entries(state.matches.tokyo).map(([k, e]) => [k.split('|')[1], e.status])); + assert.deepEqual(st, { 'SK1415-20261209-ARN-CPH': 'unpaired', 'SK1417-20261209-ARN-CPH': 'unpaired', 'SK984-20261220-HND-CPH': 'gone' }); +}); diff --git a/sas-bonus-watch.user.js b/sas-bonus-watch.user.js new file mode 100644 index 0000000..7fdc0b9 --- /dev/null +++ b/sas-bonus-watch.user.js @@ -0,0 +1,906 @@ +// ==UserScript== +// @name SAS bonus watch +// @namespace sas-bonus-finder +// @version 1.1.1 +// @description Watches SAS EuroBonus award seats from your own browser and alerts you when matching seats appear. +// @author Måns Sivertsson +// @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch +// @supportURL https://gitea.mscloud.site/mans/sas-bonus-watch/issues +// @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js +// @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js +// @match https://www.sas.se/robots.txt* +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_xmlhttpRequest +// @grant GM_notification +// @connect ntfy.sh +// @connect discord.com +// @connect hooks.slack.com +// @connect * +// @run-at document-idle +// @noframes +// ==/UserScript== + +/* + * Open https://www.sas.se/robots.txt in a tab and leave it open (pinning it helps). + * The tab turns into a dashboard where you edit the config and see matches. + * + * Requests go to sas.se from this tab, at most one every `min_request_interval` + * seconds (+ jitter), and a full sweep at most every `cycle_interval` seconds. + * 1. calendar /bff/award-finder/destinations/v1 one request per route pair, + * returns every date with seats in both directions + * 2. details /bff/award-finder/routes/v1 only for dates in your ranges + * whose calendar entry changed (or every `detail_max_age` seconds) + */ + +(function () { + 'use strict'; + + const CABINS = { AG: 'SAS Go', AP: 'SAS Plus', AB: 'SAS Business' }; + + const DEFAULT_CONFIG = { + settings: { + market: 'se-sv', + min_request_interval: 40, + jitter: 20, + cycle_interval: 900, + detail_max_age: 21600, + prefilter: true, + desktop_notifications: true, + }, + notify: [ + { type: 'ntfy', url: 'https://ntfy.sh/CHANGE-ME-to-a-long-random-topic', priority: 'high' }, + ], + watches: [ + { + name: 'Tokyo over Christmas', + enabled: true, + origins: ['ARN', 'CPH'], + destinations: ['HND', 'NRT'], + from: '2026-12-15', + to: '2027-01-10', + cabins: ['AP', 'AB'], + min_seats: 2, + max_stops: 1, + require_both_legs: true, + min_nights: 10, + max_nights: 21, + }, + ], + }; + + // ---------------------------------------------------------------- dates + + const DAY = 86400000; + const isoDate = (d) => d.toLocaleDateString('sv-SE'); // YYYY-MM-DD in local time + const todayISO = () => isoDate(new Date()); + const addDays = (iso, n) => new Date(Date.parse(iso + 'T00:00:00Z') + n * DAY).toISOString().slice(0, 10); + const daysBetween = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / DAY); + const fmtDay = (iso) => new Date(iso + 'T00:00:00Z').toLocaleDateString('en-GB', + { weekday: 'short', day: '2-digit', month: 'short', timeZone: 'UTC' }); + + function dayList(range, notBefore) { + const out = []; + for (let d = range.from < notBefore ? notBefore : range.from; d <= range.to; d = addDays(d, 1)) out.push(d); + return out; + } + + // ---------------------------------------------------------------- config + + const blank = (v) => v == null || v === ''; + + /** Old configs had separate outbound/inbound ranges; fold them into one travel window. */ + function migrateWatch(w) { + if (!w || w.from || !w.outbound) return w; + const { outbound, inbound, ...rest } = w; + return { ...rest, from: outbound.from, to: (inbound || outbound).to, + require_both_legs: w.require_both_legs ?? Boolean(inbound) }; + } + + const WATCH_KEYS = ['name', 'enabled', 'origins', 'destinations', 'from', 'to', 'cabins', 'min_seats', 'max_stops', + 'require_both_legs', 'min_nights', 'max_nights']; + + /** Returns a normalized config with only the enabled watches; throws with every problem found. */ + function validateConfig(raw) { + const errors = []; + const err = (m) => errors.push(m); + const s = { ...DEFAULT_CONFIG.settings, ...(raw.settings || {}) }; + for (const k of Object.keys(raw.settings || {})) if (!(k in DEFAULT_CONFIG.settings)) err(`settings: unknown key "${k}"`); + for (const k of ['min_request_interval', 'jitter', 'cycle_interval', 'detail_max_age']) s[k] = Number(s[k]); + if (!(s.min_request_interval >= 20)) err('settings: request interval must be at least 20 seconds'); + if (!(s.cycle_interval >= 60)) err('settings: sweep interval must be at least 60 seconds'); + + const dateOk = (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) && !isNaN(Date.parse(v)); + const airports = (v, where) => { + const list = (Array.isArray(v) ? v : String(v ?? '').split(/[\s,;/]+/)).map((c) => String(c).trim().toUpperCase()).filter(Boolean); + if (!list.length) err(`${where}: at least one airport code is required`); + list.filter((c) => !/^[A-Z]{3}$/.test(c)).forEach((c) => err(`${where}: "${c}" is not a 3-letter airport code`)); + return list; + }; + const int = (v, where, min) => { + if (blank(v)) return null; + const n = Number(v); + if (!Number.isInteger(n) || n < min) err(`${where} must be a whole number ≥ ${min}`); + return n; + }; + + const all = (raw.watches || []).map(migrateWatch).map((w, i) => { + const name = String(w.name || '').trim() || `watch ${i + 1}`; + const at = `"${name}"`; + Object.keys(w).filter((k) => !WATCH_KEYS.includes(k)).forEach((k) => err(`${at}: unknown key "${k}"`)); + const cabins = (w.cabins || Object.keys(CABINS)).map((c) => String(c).toUpperCase()); + if (!cabins.length) err(`${at}: pick at least one cabin`); + cabins.filter((c) => !CABINS[c]).forEach((c) => err(`${at}: unknown cabin "${c}", use AG, AP or AB`)); + if (!dateOk(w.from) || !dateOk(w.to)) err(`${at}: "from" and "to" must be dates (YYYY-MM-DD)`); + else if (w.to < w.from) err(`${at}: "to" is before "from"`); + const window = { from: w.from, to: w.to }; + const min_nights = int(w.min_nights, `${at}: min nights`, 0); + const max_nights = int(w.max_nights, `${at}: max nights`, 0); + if (min_nights != null && max_nights != null && max_nights < min_nights) err(`${at}: max nights is below min nights`); + return { + name, + enabled: w.enabled !== false, + origins: airports(w.origins, `${at}: origins`), + destinations: airports(w.destinations, `${at}: destinations`), + window, + outbound: window, + inbound: window, + cabins, + min_seats: int(w.min_seats, `${at}: seats`, 1) ?? 1, + max_stops: int(w.max_stops, `${at}: max stops`, 0), + require_both_legs: w.require_both_legs !== false || min_nights != null || max_nights != null, + min_nights, + max_nights, + }; + }); + if (new Set(all.map((w) => w.name)).size !== all.length) err('watch names must be unique'); + + const notify = (raw.notify || []).filter(Boolean).map((n, i) => { + const type = String(n.type || '').toLowerCase(); + if (!['ntfy', 'discord', 'slack', 'webhook'].includes(type)) err(`notification ${i + 1}: unknown type "${n.type}"`); + if (!/^https?:\/\//.test(n.url || '')) err(`notification ${i + 1}: URL must start with http(s)://`); + return { type, url: n.url, priority: n.priority }; + }); + + if (errors.length) throw new Error(errors.join('\n')); + return { settings: s, notify, watches: all.filter((w) => w.enabled) }; + } + + // ---------------------------------------------------------------- SAS API parsing + + const seatsOf = (raw) => ({ AG: +raw?.AG || 0, AP: +raw?.AP || 0, AB: +raw?.AB || 0 }); + + function parseCalendar(data, destination) { + const entry = (data || []).find((d) => d.airportCode === destination) || (data || [])[0] || {}; + const toMap = (items) => Object.fromEntries((items || []).map((i) => [i.date, seatsOf(i)])); + return { outbound: toMap(entry.availability?.outbound), inbound: toMap(entry.availability?.inbound) }; + } + + const parseFlight = (f) => ({ + flight_id: f.flightId, + departure_date: f.departureDate, + departure_time: f.departureTime || '', + arrival_date: f.arrivalDate || f.departureDate, + arrival_time: f.arrivalTime || '', + seats: seatsOf(f.availability), + stops: Math.max(0, (+f.noOfFlights || 1) - 1), + total_minutes: +f.totalTime || 0, + }); + + // ---------------------------------------------------------------- matching + + const isDirect = (w) => w.max_stops === 0; + const legKey = (o, d, direct) => `${o}-${d}-${direct ? 1 : 0}`; + const calKey = (o, d, direct, pax) => `${legKey(o, d, direct)}-${pax}`; + const sig = (seats) => JSON.stringify(seats); + const wanted = (w, seats) => w.cabins.some((c) => (seats[c] || 0) >= w.min_seats); + + function legNeeds(w) { + const pairs = w.origins.flatMap((o) => w.destinations.filter((d) => d !== o).map((d) => [o, d])); + const needs = pairs.map(([o, d]) => ({ watch: w, leg: 'outbound', origin: o, destination: d, dates: w.outbound })); + if (w.inbound) needs.push(...pairs.map(([o, d]) => ({ watch: w, leg: 'inbound', origin: d, destination: o, dates: w.inbound }))); + return needs; + } + + /** "OUT ARN→HND Mon 07 Dec 07:00→Tue 08 Dec 07:55, 1 stop (SK1417)" – everything except the seats */ + function describeFlight(m) { + const f = m.flight; + const arr = f.arrival_date === f.departure_date ? f.arrival_time : `${fmtDay(f.arrival_date)} ${f.arrival_time}`; + const stops = f.stops === 0 ? 'direct' : `${f.stops} stop${f.stops > 1 ? 's' : ''}`; + return `${m.leg === 'outbound' ? 'OUT' : 'RET'} ${m.origin}→${m.destination} ${fmtDay(f.departure_date)} ` + + `${f.departure_time}→${arr}, ${stops} (${f.flight_id.split('-')[0]})`; + } + const seatText = (cabin, n) => `${CABINS[cabin]} ×${n}`; + + function matchFlights(need, flights) { + const w = need.watch; + const out = []; + for (const f of flights) { + if (f.departure_date < need.dates.from || f.departure_date > need.dates.to) continue; + if (w.max_stops != null && f.stops > w.max_stops) continue; + for (const c of w.cabins) { + if ((f.seats[c] || 0) < w.min_seats) continue; + const m = { key: `${need.leg}|${f.flight_id}|${c}`, leg: need.leg, origin: need.origin, + destination: need.destination, flight: f, cabin: c, seats: f.seats[c] }; + m.desc = describeFlight(m); + m.line = `${m.desc}, ${seatText(c, m.seats)}`; + out.push(m); + } + } + return out; + } + + function applyRoundTrip(w, matches) { + if (!w.require_both_legs) return matches; + const outs = matches.filter((m) => m.leg === 'outbound'); + const ins = matches.filter((m) => m.leg === 'inbound'); + const ok = (o, i) => { + const nights = daysBetween(o.flight.departure_date, i.flight.departure_date); + return i.flight.departure_date >= o.flight.arrival_date && + (w.min_nights == null || nights >= w.min_nights) && + (w.max_nights == null || nights <= w.max_nights); + }; + return [...outs.filter((o) => ins.some((i) => ok(o, i))), ...ins.filter((i) => outs.some((o) => ok(o, i)))]; + } + + // ---------------------------------------------------------------- watcher + + /** + * api: { calendar(o, d, direct, pax), flights(o, d, day, direct) } (both may throw Blocked) + * state: plain object, persisted by save() + * notify: async (title, body, data) => bool + */ + function createWatcher({ cfg, api, state, save, notify, log, now = Date.now }) { + state.calendar ||= {}; + state.flights ||= {}; + state.matches ||= {}; + state.events ||= []; + + // kinds: new | back | up | down | gone | unpaired + function event(watch, kind, line) { + state.events.unshift({ ts: new Date(now()).toISOString(), watch, kind, line }); + state.events.length = Math.min(state.events.length, 300); + } + + function prune(today) { + for (const k of Object.keys(state.flights)) if (k.split('|')[1] < today) delete state.flights[k]; + const names = new Set(cfg.watches.map((w) => w.name)); + for (const k of Object.keys(state.matches)) if (!names.has(k)) delete state.matches[k]; + // keep lost matches visible for two weeks (or until departure), then forget them + const cutoff = new Date(now() - 14 * DAY).toISOString(); + for (const entries of Object.values(state.matches)) { + for (const [k, e] of Object.entries(entries)) { + if (e.date < today || (e.status && e.status !== 'active' && e.since < cutoff)) delete entries[k]; + } + } + } + + function estimate() { + const today = todayISO(); + const cal = new Set(); + let days = 0; + for (const w of cfg.watches) { + for (const n of legNeeds(w)) { + cal.add([[n.origin, n.destination].sort().join('-'), isDirect(w), w.min_seats].join('|')); + days += dayList(n.dates, today).length; + } + } + return cfg.settings.prefilter + ? `${cal.size} calendar request(s) per sweep + detail requests for changed dates (worst case ${days})` + : `${days} detail request(s) per sweep (prefilter off)`; + } + + async function runCycle(notifyOn = true) { + const s = cfg.settings; + const today = todayISO(); + prune(today); + const needs = cfg.watches.flatMap(legNeeds); + + // 1. calendars: one request covers both directions; passengers=N only counts flights with N seats + const calendars = {}; + if (s.prefilter) { + for (const n of needs) { + const direct = isDirect(n.watch), pax = n.watch.min_seats; + if (calendars[calKey(n.origin, n.destination, direct, pax)]) continue; + const c = await api.calendar(n.origin, n.destination, direct, pax); + calendars[calKey(n.origin, n.destination, direct, pax)] = c.outbound; + calendars[calKey(n.destination, n.origin, direct, pax)] = c.inbound; + } + } + + // 2. which (leg, day) need flight details + const wantedDays = {}; + const daySigs = {}; + for (const n of needs) { + const direct = isDirect(n.watch), pax = n.watch.min_seats; + const lk = legKey(n.origin, n.destination, direct); + let days = dayList(n.dates, today); + if (s.prefilter) { + const cal = calendars[calKey(n.origin, n.destination, direct, pax)]; + days = days.filter((d) => cal[d] && wanted(n.watch, cal[d])); + for (const d of days) (daySigs[`${lk}|${d}`] ||= {})[pax] = sig(cal[d]); + } + wantedDays[lk] ||= new Set(); + days.forEach((d) => wantedDays[lk].add(d)); + } + + // 3. details for changed days, cache for unchanged + const flights = {}; + for (const [lk, days] of Object.entries(wantedDays)) { + const [o, d, directFlag] = lk.split('-'); + const direct = directFlag === '1'; + for (const day of [...days].sort()) { + const fk = `${lk}|${day}`; + const cached = state.flights[fk]; + let unchanged = s.prefilter && cached && now() - cached.at < s.detail_max_age * 1000; + for (const [pax, sg] of Object.entries(daySigs[fk] || {})) { + unchanged = unchanged && (state.calendar[calKey(o, d, direct, pax)] || {})[day] === sg; + } + if (unchanged) { flights[fk] = cached.flights; continue; } + log(`fetching flights ${o}→${d} ${day}`); + const fl = await api.flights(o, d, day, direct); + state.flights[fk] = { at: now(), flights: fl }; + save(); + flights[fk] = fl; + } + } + + // 4. everything fetched: commit calendars (an aborted sweep leaves them untouched, so it is redone) + for (const [k, cal] of Object.entries(calendars)) { + state.calendar[k] = Object.fromEntries(Object.entries(cal).map(([d, seats]) => [d, sig(seats)])); + } + + // 5. match + diff + notify + let total = 0; + for (const w of cfg.watches) { + let matches = []; + for (const n of legNeeds(w)) { + const lk = legKey(n.origin, n.destination, isDirect(w)); + for (const day of wantedDays[lk] || []) { + if (day >= n.dates.from && day <= n.dates.to) matches.push(...matchFlights(n, flights[`${lk}|${day}`] || [])); + } + } + const raw = [...new Map(matches.map((m) => [m.key, m])).values()]; + total += await diff(w, applyRoundTrip(w, raw), raw, notifyOn); + } + save(); + return total; + } + + /** + * Updates state.matches[w.name]. Each entry: + * { desc, cabin, seats, peak, leg, date, time, status: active|gone|unpaired, since, first, notified } + * "unpaired" = still has seats but no longer combines with a return (require_both_legs). + * Lost entries stay listed (see prune) so you can see what was missed. + */ + async function diff(w, matches, raw, notifyOn) { + const before = state.matches[w.name] || {}; + const current = Object.fromEntries(matches.map((m) => [m.key, m])); + const rawByKey = Object.fromEntries(raw.map((m) => [m.key, m])); + const ts = new Date(now()).toISOString(); + const after = {}; + + for (const [k, m] of Object.entries(current)) { + const old = before[k] && { status: 'active', desc: before[k].line, peak: before[k].seats, ...before[k] }; + const e = { desc: m.desc, cabin: m.cabin, seats: m.seats, peak: Math.max(old?.peak ?? 0, m.seats), leg: m.leg, + date: m.flight.departure_date, time: m.flight.departure_time, status: 'active', + since: old?.status === 'active' ? old.since || old.first : ts, first: old?.first ?? ts, notified: old?.notified ?? false }; + if (!old) { + event(w.name, 'new', m.line); + } else if (old.status !== 'active') { + event(w.name, 'back', m.line); + e.notified = false; // available again: alert again + } else if (old.seats !== m.seats) { + event(w.name, m.seats < old.seats ? 'down' : 'up', `${m.desc}, ${CABINS[m.cabin]} ×${old.seats} → ×${m.seats}`); + } + after[k] = e; + } + + for (const [k, old0] of Object.entries(before)) { + if (after[k]) continue; + const old = { status: 'active', desc: old0.line, peak: old0.seats, ...old0 }; + const r = rawByKey[k]; + if (r) { + if (old.status !== 'unpaired') event(w.name, 'unpaired', `${r.line} – no matching return`); + after[k] = { ...old, seats: r.seats, peak: Math.max(old.peak, r.seats), status: 'unpaired', + since: old.status === 'unpaired' ? old.since : ts }; + } else { + if (old.status !== 'gone') event(w.name, 'gone', `${old.desc}, ${seatText(old.cabin, old.seats)}`); + after[k] = { ...old, status: 'gone', since: old.status === 'gone' ? old.since : ts }; + } + } + state.matches[w.name] = after; + + const fresh = matches.filter((m) => !after[m.key].notified) + .sort((a, b) => (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) || + (a.flight.departure_date + a.flight.departure_time).localeCompare(b.flight.departure_date + b.flight.departure_time)); + if (!fresh.length) { + log(`[${w.name}] ${matches.length} active match(es), nothing new`); + return 0; + } + const lines = fresh.map((m) => m.line); + log(`[${w.name}] ${fresh.length} new match(es):\n ${lines.join('\n ')}`); + if (notifyOn) { + const body = lines.slice(0, 25).join('\n') + (lines.length > 25 ? `\n…and ${lines.length - 25} more` : ''); + const data = { watch: w.name, matches: fresh.map((m) => ({ leg: m.leg, origin: m.origin, destination: m.destination, + cabin: m.cabin, seats: m.seats, ...m.flight })) }; + if (!(await notify(`SAS award seats: ${w.name}`, body, data))) { + log(`[${w.name}] notification failed, will retry next sweep`, 'warn'); + return fresh.length; + } + } + fresh.forEach((m) => { after[m.key].notified = true; }); + return fresh.length; + } + + return { runCycle, estimate }; + } + + const core = { validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG }; + if (typeof module === 'object' && module.exports) { module.exports = core; return; } // node tests + + // ================================================================ browser runtime + + class Blocked extends Error {} + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const gmGet = (k, d) => { try { const v = GM_getValue(k); return v === undefined ? d : JSON.parse(v); } catch { return d; } }; + const gmSet = (k, v) => GM_setValue(k, JSON.stringify(v)); + + const logLines = []; + function log(msg, level = 'info') { + const line = `${new Date().toLocaleTimeString('sv-SE')} ${level === 'info' ? '' : level.toUpperCase() + ' '}${msg}`; + (level === 'warn' ? console.warn : console.log)('[sas-bonus-watch]', msg); + logLines.unshift(line); + logLines.length = Math.min(logLines.length, 200); + ui.render(); + } + + // -- config & state + let cfg, cfgError = null; + const rawConfig = gmGet('config', DEFAULT_CONFIG); + try { cfg = validateConfig(rawConfig); } catch (e) { cfgError = e.message; } + const state = gmGet('state', {}); + const saveState = () => gmSet('state', state); + + // -- notifications + function gmPost(url, body, headers = {}) { + return new Promise((resolve, reject) => GM_xmlhttpRequest({ + method: 'POST', url, data: JSON.stringify(body), timeout: 15000, + headers: { 'Content-Type': 'application/json', ...headers }, + onload: (r) => (r.status < 300 ? resolve(r) : reject(new Error(`HTTP ${r.status} ${r.responseText?.slice(0, 200)}`))), + onerror: () => reject(new Error('network error')), + ontimeout: () => reject(new Error('timeout')), + })); + } + + const NTFY_PRIORITY = { min: 1, low: 2, default: 3, high: 4, urgent: 5, max: 5 }; + async function sendOne(n, title, body, data) { + if (n.type === 'ntfy') { + const u = new URL(n.url); // https://ntfy.sh/topic -> publish JSON to the server root + await gmPost(u.origin + '/', { topic: u.pathname.replace(/^\/+/, ''), title, message: body, tags: ['airplane'], + priority: NTFY_PRIORITY[n.priority] || +n.priority || 3 }); + } else if (n.type === 'discord') { + await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) }); + } else if (n.type === 'slack') { + await gmPost(n.url, { text: `*${title}*\n${body}` }); + } else { + await gmPost(n.url, { title, body, ...data }); + } + } + + async function notify(title, body, data = {}) { + if (cfg?.settings.desktop_notifications) { + try { GM_notification({ title, text: body, timeout: 0 }); } catch (e) { log(`desktop notification failed: ${e}`, 'warn'); } + } + if (!cfg?.notify.length) return true; + let ok = false; + for (const n of cfg.notify) { + try { await sendOne(n, title, body, data); ok = true; } catch (e) { log(`notify via ${n.type} failed: ${e.message}`, 'warn'); } + } + return ok; + } + + // -- polite client + const client = { + nextAllowed: 0, failures: 0, requests: 0, + async get(path, params) { + const s = cfg.settings; + const wait = this.nextAllowed - Date.now(); + if (wait > 0) await sleep(wait); + this.nextAllowed = Date.now() + (s.min_request_interval + Math.random() * s.jitter) * 1000; + this.requests++; + const url = `${location.origin}/bff/award-finder/${path}?${new URLSearchParams(params)}`; + let r; + try { + r = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }); + } catch (e) { + this.penalize(`network error: ${e.message}\n ${url}`); + throw new Blocked(e.message); + } + const ct = r.headers.get('content-type') || ''; + if (!r.ok || !ct.includes('json')) { + this.penalize(`HTTP ${r.status} (${ct || 'no content-type'})\n ${url}`); + throw new Blocked(`HTTP ${r.status}`); + } + if (this.failures >= 2) notify('SAS bonus watch recovered', 'Requests are succeeding again.'); + this.failures = 0; + status.blocked = false; + return r.json(); + }, + penalize(reason) { + this.failures++; + const backoff = Math.min(cfg.settings.min_request_interval * 2 ** this.failures, 3600); + this.nextAllowed = Date.now() + backoff * 1000; + log(`${reason} - backing off ${Math.round(backoff)} s`, 'warn'); + if (this.failures === 2) { + status.blocked = true; + notify('SAS bonus watch is being refused', + `${reason.split('\n')[0]}. Reload the ${location.href} tab; if sas.se shows a check, pass it.`); + } + }, + }; + + const api = { + async calendar(o, d, direct, pax) { + return parseCalendar(await client.get('destinations/v1', { market: cfg.settings.market, origin: o, destinations: d, + selectedMonth: '', passengers: pax, direct, availability: true }), d); + }, + async flights(o, d, day, direct) { + const data = await client.get('routes/v1', { market: cfg.settings.market, origin: o, destination: d, + departureDate: day, direct }); + return (data || []).map(parseFlight); + }, + }; + + // -- single-tab lock, so two open tabs don't double the request rate + const TAB_ID = Math.random().toString(36).slice(2); + function holdLock() { + const l = gmGet('lock', null); + if (l && l.id !== TAB_ID && Date.now() - l.ts < 180000) return false; + gmSet('lock', { id: TAB_ID, ts: Date.now() }); + return true; + } + window.addEventListener('pagehide', () => { if (gmGet('lock', null)?.id === TAB_ID) gmSet('lock', null); }); + + // -- main loop + const status = { phase: 'starting', lastCycle: null, nextCycle: null, blocked: false, paused: gmGet('paused', false) }; + let wake = null; + const interruptibleSleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); wake = () => { clearTimeout(t); r(); }; }); + + async function mainLoop() { + if (cfgError) { status.phase = 'config error'; ui.render(); return; } + if (!cfg.watches.length) { status.phase = 'no active watches'; ui.render(); return; } + const watcher = createWatcher({ cfg, api, state, save: saveState, notify, log }); + log(`budget: ${watcher.estimate()}; ≥${cfg.settings.min_request_interval} s between requests, sweep every ${cfg.settings.cycle_interval} s`); + setInterval(() => { if (status.phase !== 'other tab') holdLock(); }, 30000); + + for (;;) { + const started = Date.now(); + if (status.paused) { + status.phase = 'paused'; + } else if (!holdLock()) { + status.phase = 'other tab'; + log('another tab is running the watcher; this one is idle', 'warn'); + } else { + status.phase = 'sweeping'; + ui.render(); + const before = client.requests; + try { + const n = await watcher.runCycle(!gmGet('baseline', false)); + if (gmGet('baseline', false)) { gmSet('baseline', false); log('baseline recorded; future sweeps alert on new seats only'); } + log(`sweep done: ${client.requests - before} request(s), ${n} new match(es)`); + } catch (e) { + if (!(e instanceof Blocked)) { log(`sweep failed: ${e.stack || e}`, 'warn'); } else { log(`sweep aborted (${e.message}); will retry`, 'warn'); } + } + status.lastCycle = new Date(); + status.phase = 'waiting'; + } + const delay = status.phase === 'other tab' ? 60000 : Math.max(0, cfg.settings.cycle_interval * 1000 - (Date.now() - started)); + status.nextCycle = new Date(Date.now() + delay); + ui.render(); + await interruptibleSleep(delay); + } + } + + // -- dashboard + const ui = (() => { + const h = (tag, attrs = {}, ...kids) => { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'checked' || k === 'disabled' || k === 'value' || k === 'selected') el[k] = v; + else if (v != null && v !== false) el.setAttribute(k, v === true ? '' : v); + } + for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid); + return el; + }; + const css = ` + :root { --bg:#fafaf9; --fg:#1c1917; --muted:#78716c; --card:#fff; --line:#e7e5e4; --acc:#1d4ed8; --ok:#15803d; --warn:#b45309; --bad:#b91c1c; --input:#fff; } + @media (prefers-color-scheme: dark) { :root { --bg:#0c0a09; --fg:#e7e5e4; --muted:#a8a29e; --card:#1c1917; --line:#292524; --acc:#60a5fa; --ok:#4ade80; --warn:#fbbf24; --bad:#f87171; --input:#0c0a09; } } + * { box-sizing: border-box; } + body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 system-ui, sans-serif; } + main { max-width: 1200px; margin: 0 auto; padding: 24px 16px 64px; } + h1 { font-size: 20px; margin: 0 0 4px; } h2 { font-size: 15px; margin: 28px 0 8px; } h3 { font-size: 13px; margin: 16px 0 6px; color: var(--muted); } + h3:first-child { margin-top: 0; } + .muted { color: var(--muted); } .small { font-size: 12px; } + .pill { display:inline-block; padding:1px 8px; border-radius:99px; font-size:12px; border:1px solid currentColor; margin-left:8px; vertical-align:2px; } + .ok { color: var(--ok); } .warn { color: var(--warn); } .bad { color: var(--bad); } + .card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; } + .btns { display:flex; flex-wrap:wrap; gap:8px; margin: 12px 0; align-items: center; } + button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; } + button.primary { background: var(--acc); border-color: var(--acc); color: #fff; } + button.icon { padding: 2px 8px; color: var(--muted); } + button:disabled { opacity: .5; cursor: default; } + table { width:100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } + td, th { text-align:left; padding: 4px 6px 4px 0; border-bottom: 1px solid var(--line); vertical-align: middle; } + th { font-weight: 600; color: var(--muted); font-size: 12px; white-space: nowrap; } + input, select { font: inherit; font-size: 13px; color: var(--fg); background: var(--input); border: 1px solid var(--line); border-radius: 5px; padding: 3px 5px; } + input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--acc); vertical-align: -3px; } + input.code { width: 8.5em; text-transform: uppercase; } input.name { width: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; } + label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; } + tr.off td { opacity: .45; } tr.off td:first-child { opacity: 1; } + td.seats { white-space: nowrap; font-variant-numeric: tabular-nums; } + tr.gone td { color: var(--bad); } tr.gone td.desc, tr.gone td.seats { text-decoration: line-through; } + tr.unpaired td.desc, tr.unpaired td.seats { color: var(--muted); } + pre { margin:0; white-space: pre-wrap; word-break: break-word; font: 12px/1.5 ui-monospace, monospace; max-height: 320px; overflow:auto; } + textarea { width:100%; min-height: 360px; font: 12px/1.5 ui-monospace, monospace; background: var(--input); color: var(--fg); border:1px solid var(--line); border-radius:8px; padding:10px; } + details > summary { cursor: pointer; color: var(--muted); margin: 10px 0 6px; } + .err { white-space: pre-wrap; color: var(--bad); } + .scroll { overflow-x: auto; } + `; + + // ---- live status --------------------------------------------------------- + const live = {}; + + function render() { + if (!live.header) return; + const t = (d) => (d ? d.toLocaleTimeString('sv-SE') : '–'); + const phase = cfgError ? 'config error' : status.blocked ? 'blocked – reload this tab' : status.phase; + const cls = cfgError || status.blocked ? 'bad' : ['paused', 'other tab', 'no active watches'].includes(status.phase) ? 'warn' : 'ok'; + live.header.replaceChildren( + h('h1', {}, 'SAS bonus watch', h('span', { class: `pill ${cls}` }, phase)), + h('div', { class: 'muted' }, + `Last sweep ${t(status.lastCycle)} · next ${t(status.nextCycle)} · ${client.requests} request(s) since load · keep this tab open`)); + live.pause.textContent = status.paused ? 'Resume' : 'Pause'; + + const when = (iso) => new Date(iso).toLocaleString('sv-SE', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + const order = { active: 0, unpaired: 1, gone: 2 }; + live.matches.replaceChildren(...(cfg?.watches || []).map((w) => { + const rows = Object.values(state.matches?.[w.name] || {}).map((r) => ({ status: 'active', desc: r.line, peak: r.seats, ...r })) + .sort((a, b) => order[a.status] - order[b.status] || (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) || + (a.date + a.time).localeCompare(b.date + b.time)); + const active = rows.filter((r) => r.status === 'active').length; + const seatsCell = (r) => h('td', { class: 'seats' }, + r.cabin ? `${CABINS[r.cabin].replace('SAS ', '')} ×${r.seats}` : '', + r.status !== 'gone' && r.peak > r.seats ? h('span', { class: 'bad', title: `was ×${r.peak}` }, ` −${r.peak - r.seats}`) : null); + const statusCell = (r) => r.status === 'gone' + ? h('td', { class: 'bad' }, `gone ${when(r.since)}`) + : r.status === 'unpaired' + ? h('td', { class: 'muted', title: 'Still has seats, but no return in your window fits with it' }, 'no matching return') + : h('td', { class: 'muted' }, `since ${when(r.first)}`); + return h('div', { class: 'card', style: 'margin-bottom:10px' }, + h('strong', {}, w.name), ' ', + h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` + + ` · ${w.cabins.join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : '') + + ` · ${active} bookable`), + rows.length + ? h('div', { class: 'scroll' }, h('table', {}, + h('tr', {}, h('th', {}, 'Flight'), h('th', {}, 'Seats'), h('th', {}, 'Status')), + rows.map((r) => h('tr', { class: r.status }, h('td', { class: 'desc' }, r.desc), seatsCell(r), statusCell(r))))) + : h('div', { class: 'muted', style: 'margin-top:6px' }, 'No matching seats right now.')); + })); + + const mark = { new: ['+', 'ok'], back: ['+', 'ok'], up: ['▲', 'ok'], down: ['▼', 'bad'], gone: ['−', 'bad'], unpaired: ['○', 'muted'] }; + const events = (state.events || []).slice(0, 80); + live.events.replaceChildren(...(events.length ? events.map((e) => { + const [sym, cls] = mark[e.kind] || ['−', 'bad']; + return h('div', {}, h('span', { class: 'muted' }, `${when(e.ts)} `), h('span', { class: cls }, `${sym} `), + h('span', { class: 'muted' }, `[${e.watch}] `), e.kind === 'back' ? 'back: ' : '', e.line); + }) : ['Nothing yet.'])); + live.log.textContent = logLines.join('\n'); + } + + // ---- config editor (tables edit a draft; JSON is export/import of the same draft) ---- + const clone = (o) => JSON.parse(JSON.stringify(o)); + const normalizeDraft = (raw) => { + const d = clone(raw || {}); + d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) }; + d.notify = d.notify || []; + d.watches = (d.watches || []).map(migrateWatch).map((w) => ({ enabled: true, require_both_legs: true, ...w })); + return d; + }; + let draft = normalizeDraft(rawConfig); + let dirty = false; + const ed = {}; + + const setDirty = (on) => { + dirty = on; + ed.save.disabled = ed.discard.disabled = !on; + ed.dirtyNote.textContent = on ? 'Unsaved changes' : ''; + if (on) ed.errors.textContent = ''; + }; + const bind = (obj, key, parse = (v) => v) => ({ + oninput: (e) => { obj[key] = parse(e.target.type === 'checkbox' ? e.target.checked : e.target.value); setDirty(true); }, + }); + const optInt = (v) => (v === '' ? null : Number(v)); + const codes = (v) => String(v).split(/[\s,;/]+/).map((c) => c.trim().toUpperCase()).filter(Boolean); + const removeBtn = (list, i, title) => h('button', { class: 'icon', title, onclick: () => { list.splice(i, 1); setDirty(true); renderEditor(); } }, '✕'); + + function watchRow(w, i) { + const cabinBoxes = Object.entries(CABINS).map(([code, label]) => h('label', { class: 'cab', title: label }, + h('input', { type: 'checkbox', checked: (w.cabins || []).includes(code), onchange: (e) => { + const set = new Set(w.cabins || []); + if (e.target.checked) set.add(code); else set.delete(code); + w.cabins = Object.keys(CABINS).filter((c) => set.has(c)); + setDirty(true); + } }), ` ${label.replace('SAS ', '')}`)); + const tr = h('tr', { class: w.enabled === false ? 'off' : null }, + h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, + onchange: (e) => { w.enabled = e.target.checked; tr.className = w.enabled ? '' : 'off'; setDirty(true); } })), + h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })), + h('td', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })), + h('td', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })), + h('td', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })), + h('td', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })), + h('td', { style: 'white-space:nowrap' }, cabinBoxes), + h('td', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })), + h('td', {}, h('select', { ...bind(w, 'max_stops', optInt) }, + [['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) => + h('option', { value: v, selected: String(w.max_stops ?? '') === v }, l)))), + h('td', { title: 'Only alert when an outbound and a return both fit in the window' }, + h('input', { type: 'checkbox', checked: w.require_both_legs !== false, ...bind(w, 'require_both_legs') })), + h('td', { style: 'white-space:nowrap' }, + h('input', { class: 'num', type: 'number', min: 0, placeholder: 'min', value: w.min_nights ?? '', ...bind(w, 'min_nights', optInt) }), + ' – ', + h('input', { class: 'num', type: 'number', min: 0, placeholder: 'max', value: w.max_nights ?? '', ...bind(w, 'max_nights', optInt) })), + h('td', {}, removeBtn(draft.watches, i, 'Remove watch'))); + return tr; + } + + function notifyRow(n, i) { + return h('tr', {}, + h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) => + h('option', { value: t, selected: n.type === t }, t)))), + h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })), + h('td', {}, h('select', { title: 'Priority (ntfy only)', ...bind(n, 'priority') }, ['', 'low', 'default', 'high', 'urgent'].map((p) => + h('option', { value: p, selected: (n.priority || '') === p }, p || '–')))), + h('td', {}, removeBtn(draft.notify, i, 'Remove notification'))); + } + + function renderEditor() { + const st = draft.settings; + const numSetting = (key, label, min) => h('label', { style: 'margin-right:16px; white-space:nowrap' }, `${label} `, + h('input', { class: 'num', type: 'number', min, value: st[key], ...bind(st, key, Number) })); + ed.tables.replaceChildren( + h('h3', {}, 'Watches'), + h('div', { class: 'small muted', style: 'margin-bottom:6px' }, + 'Finds outbound and return flights departing between Earliest and Latest. Airports are comma-separated codes; every From × To pair is checked.'), + h('div', { class: 'scroll' }, h('table', {}, + h('tr', {}, ['On', 'Name', 'From', 'To', 'Earliest', 'Latest', 'Cabins', 'Seats', 'Stops', 'Both legs', 'Nights', ''] + .map((c) => h('th', {}, c))), + draft.watches.map(watchRow))), + h('div', { class: 'btns' }, h('button', { onclick: () => { + const today = todayISO(); + draft.watches.push({ name: `watch ${draft.watches.length + 1}`, enabled: true, origins: ['ARN'], destinations: [], + from: today, to: addDays(today, 14), cabins: ['AG', 'AP', 'AB'], min_seats: 1, max_stops: null, require_both_legs: true }); + setDirty(true); + renderEditor(); + } }, '+ Add watch')), + + h('h3', {}, 'Notifications'), + h('div', { class: 'scroll' }, h('table', {}, + h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))), + draft.notify.map(notifyRow))), + h('div', { class: 'btns' }, + h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); setDirty(true); renderEditor(); } }, + '+ Add notification'), + h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }), + ' Desktop notifications')), + + h('details', {}, h('summary', {}, 'Request pacing'), + h('div', { class: 'small' }, + numSetting('min_request_interval', 'Seconds between requests (≥ 20)', 20), + numSetting('jitter', '+ random up to', 0), + numSetting('cycle_interval', 'Seconds between sweeps', 60)))); + } + + function jsonSection() { + const json = h('textarea', { spellcheck: 'false' }); + const msg = h('div', { class: 'small' }); + const apply = () => { + try { + const parsed = normalizeDraft(JSON.parse(json.value)); + validateConfig(parsed); + draft = parsed; + setDirty(true); + renderEditor(); + msg.className = 'small ok'; + msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.'; + } catch (e) { msg.className = 'small err'; msg.textContent = e.message; } + }; + const file = h('input', { type: 'file', accept: '.json,application/json', style: 'display:none', onchange: async (e) => { + const f = e.target.files[0]; + if (f) { json.value = await f.text(); apply(); } + e.target.value = ''; + } }); + return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(draft, null, 2); } }, + h('summary', {}, 'JSON – export / import / reset'), + h('div', { class: 'small muted', style: 'margin-bottom:6px' }, + 'The config as currently edited above. Edit or paste JSON and apply it to the tables, or save it to a file as a backup.'), + json, + h('div', { class: 'btns' }, + h('button', { onclick: apply }, 'Apply JSON to tables'), + h('button', { onclick: () => { + const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' })), + download: `sas-bonus-watch-${todayISO()}.json` }); + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } }, 'Download .json'), + h('button', { onclick: () => file.click() }, 'Load file…'), + file, + h('button', { onclick: () => { if (confirm('Replace the config with the example?')) { json.value = JSON.stringify(DEFAULT_CONFIG, null, 2); apply(); } } }, + 'Reset to example')), + msg); + } + + function mount() { + document.title = 'SAS bonus watch'; + document.head.append(h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }), h('style', {}, css)); + live.header = h('div'); + live.matches = h('div'); + live.events = h('pre'); + live.log = h('pre'); + live.pause = h('button', { onclick: () => { + status.paused = !status.paused; + gmSet('paused', status.paused); + if (!status.paused) wake?.(); + render(); + } }); + + ed.tables = h('div'); + ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : ''); + ed.dirtyNote = h('span', { class: 'warn small' }); + ed.save = h('button', { class: 'primary', disabled: true, onclick: () => { + try { + validateConfig(draft); + gmSet('config', draft); + dirty = false; + location.reload(); + } catch (e) { ed.errors.textContent = e.message; } + } }, 'Save & restart'); + ed.discard = h('button', { disabled: true, onclick: () => { + draft = normalizeDraft(rawConfig); + setDirty(false); + ed.errors.textContent = ''; + renderEditor(); + } }, 'Discard changes'); + + document.body.replaceChildren(h('main', {}, + live.header, + h('div', { class: 'btns' }, + h('button', { class: 'primary', onclick: () => { status.paused = false; gmSet('paused', false); wake?.(); } }, 'Sweep now'), + live.pause, + h('button', { onclick: async () => log((await notify('SAS bonus watch: test', 'Notifications work.')) + ? 'test notification sent' : 'test notification failed') }, 'Test notification'), + h('button', { onclick: () => { gmSet('baseline', true); log('next sweep records current seats without alerting'); wake?.(); } }, + 'Baseline (no alerts)'), + h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } }, + 'Reset state')), + h('h2', {}, 'Current matches'), live.matches, + h('h2', {}, 'Config'), + h('div', { class: 'card' }, ed.tables, + h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote), + ed.errors, + jsonSection()), + h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events), + h('h2', {}, 'Log'), h('div', { class: 'card' }, live.log))); + window.addEventListener('beforeunload', (e) => { if (dirty) e.preventDefault(); }); + renderEditor(); + render(); + } + return { mount, render }; + })(); + + ui.mount(); + mainLoop(); +})();