Files
sas-bonus-watch/sas-bonus-watch.user.js
T
Måns SivertssonandClaude Opus 5.5 f1acd53a9c 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 <noreply@anthropic.com>
2026-10-12 16:06:40 +02:00

907 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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();
})();