Files
sas-bonus-watch/sas-bonus-watch.user.js
T
Måns SivertssonandClaude Opus 5.5 9caff094ac Export, Import and Reset; JSON editor removed
- Export downloads one file with the saved config, all match data and
  history, the flight cache and view settings
- Import restores such a file after a confirmation; older config-only
  .json files are accepted and keep existing match data
- Reset (with a warning) deletes everything and starts over with new
  random example watches; replaces "Reset state"
- The JSON config section is gone
- Old ntfy targets no longer stop the script: they are ignored at
  runtime and on import, shown as "no longer supported" in the editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:08:47 +02:00

1164 lines
63 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.5.0
// @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 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';
// SAS award-finder cabin codes -> display names, and the names used in the config
const CABINS = { AG: 'Economy', AP: 'Premium', AB: 'Business' };
const CABIN_KEYS = { AG: 'economy', AP: 'premium', AB: 'business' };
const CABIN_ALIASES = { economy: 'AG', go: 'AG', ag: 'AG', premium: 'AP', 'premium economy': 'AP', plus: 'AP', ap: 'AP',
business: 'AB', ab: 'AB' };
const cabinCode = (c) => CABIN_ALIASES[String(c).trim().toLowerCase()];
const cabinKey = (c) => CABIN_KEYS[cabinCode(c)] || String(c);
const NOTIFY_TYPES = ['zulip', 'slack', 'discord', 'webhook'];
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: [],
watches: [], // a fresh install gets exampleConfig() instead
};
// ---------------------------------------------------------------- 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;
}
// ---------------------------------------------------------------- example watches
/*
* A fresh install (and "New random example") gets a few themed watches from ARN with a destination that
* suits the name and dates at the next occurrence of the theme. Every destination below is an SAS award
* destination from ARN (award-finder destinations list, October 2026).
*/
const PLACES = {
AGA: 'Agadir', AGP: 'Málaga', AMS: 'Amsterdam', ATH: 'Athens', BCN: 'Barcelona', BER: 'Berlin', BOO: 'Bodø',
BUD: 'Budapest', CDG: 'Paris', DUB: 'Dublin', EDI: 'Edinburgh', EWR: 'New York', FCO: 'Rome', FLR: 'Florence',
FNC: 'Madeira', FUE: 'Fuerteventura', GVA: 'Geneva', HND: 'Tokyo', ICN: 'Seoul', INN: 'Innsbruck', JFK: 'New York',
KEF: 'Reykjavik', KKN: 'Kirkenes', KLR: 'Kalmar', KRK: 'Kraków', KRN: 'Kiruna', LAX: 'Los Angeles', LCA: 'Larnaca',
LHR: 'London', LIS: 'Lisbon', LPA: 'Gran Canaria', LYR: 'Svalbard', MIA: 'Miami', MLA: 'Malta', NCE: 'Nice',
OPO: 'Porto', OSD: 'Åre', PRG: 'Prague', RAK: 'Marrakech', RVN: 'Rovaniemi', SCR: 'Sälen', SFO: 'San Francisco',
SVQ: 'Seville', SZG: 'Salzburg', TFS: 'Tenerife', TOS: 'Tromsø', VBY: 'Visby', VCE: 'Venice', VIE: 'Vienna',
YYZ: 'Toronto',
};
const iso = (y, m, d) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const weekday = (d) => new Date(d + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
const nextWeekday = (d, wd) => addDays(d, (wd - weekday(d) + 7) % 7);
function easterSunday(y) { // anonymous Gregorian algorithm
const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4), e = b % 4;
const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
return iso(y, Math.floor((h + l - 7 * m + 114) / 31), ((h + l - 7 * m + 114) % 31) + 1);
}
const isoWeekMonday = (y, week) => { // Monday of ISO week `week`
const jan4 = iso(y, 1, 4);
return addDays(jan4, -((weekday(jan4) + 6) % 7) + (week - 1) * 7);
};
/** window(year, today, rnd) -> {from, to}; yearly themes try this year, then next. */
const THEMES = [
{ names: ['Christmas in {city}', 'Visiting Santa in {city}', 'Hiding from Christmas shopping in {city}', 'Mulled wine in {city}'],
places: ['RVN', 'TOS', 'KRN', 'KEF', 'LYR', 'VIE', 'SZG'], nights: [4, 9],
window: (y) => ({ from: iso(y, 12, 20), to: iso(y, 12, 30) }) },
{ names: ["New Year's Eve in {city}", 'Fireworks and regrets in {city}', 'Counting down in {city}'],
places: ['KEF', 'BER', 'LHR', 'EDI', 'PRG', 'BCN', 'JFK'], nights: [2, 4],
window: (y) => ({ from: iso(y, 12, 29), to: iso(y + 1, 1, 2) }) },
{ names: ['Getaway weekend in {city}', '48 hours in {city}', 'A very long lunch in {city}', 'Pretending to be a local in {city}'],
places: ['BCN', 'PRG', 'FCO', 'CDG', 'LHR', 'BER', 'AMS', 'LIS', 'BUD', 'KRK', 'VIE', 'EDI', 'DUB', 'VCE', 'FLR', 'OPO'],
nights: [2, 3],
window: (y, today, rnd) => { // a Friday 3-10 weeks from now, through Monday
const fri = addDays(nextWeekday(addDays(today, 21), 5), 7 * Math.floor(rnd() * 8));
return { from: fri, to: addDays(fri, 3) };
} },
{ names: ['Escaping the dark to {city}', 'Vitamin D hunt in {city}', 'Thawing out in {city}'],
places: ['TFS', 'LPA', 'FUE', 'FNC', 'AGP', 'AGA', 'RAK', 'MLA', 'LCA', 'MIA'], nights: [6, 12],
window: (y, today, rnd) => { const from = addDays(iso(y, 1, 10), Math.floor(rnd() * 50)); return { from, to: addDays(from, 14) }; } },
{ names: ['Ski week in {city}', 'Après-ski practice in {city}', 'Falling down mountains in {city}'],
places: ['INN', 'SZG', 'GVA', 'OSD', 'SCR'], nights: [5, 8],
window: (y) => { const mon = isoWeekMonday(y, 9); return { from: addDays(mon, -2), to: addDays(mon, 6) }; } }, // Stockholm sportlov
{ names: ["Valentine's escape to {city}", 'Romantic disaster in {city}', 'Candlelit dinner in {city}'],
places: ['CDG', 'VCE', 'FCO', 'LIS', 'FLR', 'NCE'], nights: [2, 3], seats: 2,
window: (y) => ({ from: iso(y, 2, 12), to: iso(y, 2, 16) }) },
{ names: ['Easter egg hunt in {city}', 'Easter witch flight to {city}', 'Chocolate eggs in {city}'],
places: ['FCO', 'FLR', 'SVQ', 'ATH', 'LIS', 'AGP'], nights: [3, 4],
window: (y) => { const sun = easterSunday(y); return { from: addDays(sun, -3), to: addDays(sun, 1) }; } },
{ names: ['Midsummer in {city}', 'Dancing like small frogs in {city}', 'Midnight sun in {city}'],
places: ['VBY', 'KLR', 'KRN', 'TOS', 'BOO', 'LYR'], nights: [2, 3],
window: (y) => { const eve = nextWeekday(iso(y, 6, 19), 5); return { from: addDays(eve, -1), to: addDays(eve, 2) }; } },
{ names: ['Bucket list: {city}', 'Finally going to {city}', 'Long-haul daydream: {city}'],
places: ['HND', 'ICN', 'LAX', 'SFO', 'EWR', 'MIA', 'YYZ'], nights: [10, 21], cabins: ['economy', 'premium', 'business'],
window: (y, today, rnd) => { const from = addDays(today, 60 + Math.floor(rnd() * 120)); return { from, to: addDays(from, 21) }; } },
];
const pick = (list, rnd) => list[Math.floor(rnd() * list.length)];
/** Next window of a theme that starts at least two weeks from today. */
function themeWindow(theme, today, rnd) {
const y = Number(today.slice(0, 4));
for (const year of [y, y + 1]) {
const w = theme.window(year, today, rnd);
if (w.from >= addDays(today, 14)) return w;
}
return null;
}
function exampleWatch(theme, today, rnd = Math.random) {
const code = pick(theme.places, rnd);
const w = themeWindow(theme, today, rnd);
return {
name: pick(theme.names, rnd).replace('{city}', PLACES[code]),
enabled: true,
origins: ['ARN'],
destinations: [code],
from: w.from,
to: w.to,
cabins: theme.cabins || ['economy', 'premium'],
min_seats: theme.seats || 1,
max_stops: null,
require_both_legs: true,
min_nights: theme.nights[0],
max_nights: theme.nights[1],
};
}
/** A config with `count` themed watches, chosen among themes that come up within ~7 months. */
function exampleConfig(today = todayISO(), rnd = Math.random, count = 2) {
const soon = THEMES.map((t) => ({ t, w: themeWindow(t, today, () => 0) }))
.filter((x) => x.w && x.w.from <= addDays(today, 210)).map((x) => x.t);
const chosen = [];
while (chosen.length < count && soon.length) chosen.push(soon.splice(Math.floor(rnd() * soon.length), 1)[0]);
return { settings: { ...DEFAULT_CONFIG.settings }, notify: [], watches: chosen.map((t) => exampleWatch(t, today, rnd)) };
}
// ---------------------------------------------------------------- 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 rawCabins = w.cabins || Object.values(CABIN_KEYS);
if (!rawCabins.length) err(`${at}: pick at least one cabin`);
rawCabins.filter((c) => !cabinCode(c)).forEach((c) => err(`${at}: unknown cabin "${c}", use economy, premium or business`));
const cabins = Object.keys(CABINS).filter((code) => rawCabins.some((c) => cabinCode(c) === code));
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 (type === 'ntfy') err(`notification ${i + 1}: ntfy is no longer supported, use zulip, slack, discord or webhook`);
else if (!NOTIFY_TYPES.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 };
});
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 };
}
/**
* Reads an Export file ({app, data: {config, state, ...prefs}}) or, for older backups, a plain config.
* Throws with a readable message when the file can't be used.
*/
function parseBackup(text) {
// old ntfy targets are ignored at runtime (removed in 1.4.0), so they shouldn't block an import either
const check = (c) => validateConfig({ ...c, notify: (c.notify || []).filter((n) => String(n?.type).toLowerCase() !== 'ntfy') });
let d;
try { d = JSON.parse(text); } catch { throw new Error('the file is not valid JSON'); }
if (d && d.app === 'sas-bonus-watch' && d.data) {
const { config, state, ...prefs } = d.data;
if (!config) throw new Error('the file has no config');
check(config);
if (state != null && (typeof state !== 'object' || Array.isArray(state))) throw new Error('the file has invalid match data');
const tracked = Object.values(state?.matches || {}).reduce((n, m) => n + Object.keys(m || {}).length, 0);
return { full: true, config, state: state || {}, prefs, exportedAt: d.exported_at, tracked };
}
if (d && typeof d === 'object' && (d.watches || d.settings || d.notify)) {
check(d);
return { full: false, config: d };
}
throw new Error('this is not a SAS bonus watch export');
}
const core = { parseBackup, CABINS, THEMES, PLACES, exampleConfig, exampleWatch, easterSunday, 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;
let rawConfig = gmGet('config', null);
if (!rawConfig) { // first run: start from a random themed example, saved so it doesn't change on every reload
rawConfig = exampleConfig();
gmSet('config', rawConfig);
}
// ntfy was removed in 1.4.0: ignore old ntfy targets at runtime instead of stopping, and ask to remove them
const isNtfy = (n) => String(n?.type).toLowerCase() === 'ntfy';
const legacyNtfy = (rawConfig.notify || []).filter(isNtfy).length;
const cfgNotice = legacyNtfy
? `${legacyNtfy} ntfy notification target(s) are ignored: ntfy is no longer supported. Remove the row and Save.` : '';
try {
cfg = validateConfig({ ...rawConfig, notify: (rawConfig.notify || []).filter((n) => !isNtfy(n)) });
} catch (e) { cfgError = e.message; }
const state = gmGet('state', {});
let frozen = false; // set while importing/resetting so a sweep in progress can't write old data back
const saveState = () => { if (!frozen) gmSet('state', state); };
const PREF_KEYS = ['collapsed', 'matchSort', 'paused'];
// -- 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')),
}));
}
async function sendOne(n, title, body, data) {
if (n.type === 'zulip') {
// Zulip's Slack-compatible webhook (…/api/v1/external/slack_incoming?api_key=…&stream=…): Slack *bold* is
// converted; "channel" becomes the topic unless the URL sets &topic=
await gmPost(n.url, { text: `*${title}*\n${body}`, channel: data.watch || 'SAS bonus watch' });
} else if (n.type === 'slack') {
await gmPost(n.url, { text: `*${title}*\n${body}` });
} else if (n.type === 'discord') {
await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) });
} 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; }
if (cfgNotice) log(cfgNotice, 'warn');
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 === '$bind') bound.set(el, v);
else 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.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); }
.btns .spacer { flex: 1; }
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 3px; 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: 17em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; }
label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; }
.wlist { border-top: 1px solid var(--line); }
.wrow { padding: 8px 4px; border-bottom: 1px solid var(--line); }
.wline { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: flex-end; }
.wline + .wline { margin-top: 4px; padding-left: 34px; }
.field { display: flex; flex-direction: column; gap: 2px; padding: 2px 4px; border-radius: 4px; }
.field > .cap { font-size: 11px; color: var(--muted); }
.field.on { align-items: center; } .field.on > input { margin: 4px 0; }
.cabs, .nights { display: flex; align-items: center; gap: 2px; white-space: nowrap; min-height: 27px; }
.wrow .rm { margin-left: auto; align-self: center; }
.wrow.off .field:not(:first-child), .wrow.off .wline + .wline { opacity: .45; }
/* unsaved edits: yellow = changed, green = new row, red = row will be removed on save */
.changed { background: color-mix(in srgb, var(--warn) 13%, transparent); }
td.changed, label.changed { border-radius: 4px; } label.changed { padding: 2px 4px; }
.changed input:not([type=checkbox]), .changed select {
background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
tr.added td, .wrow.added, .added .changed { background: color-mix(in srgb, var(--ok) 13%, transparent); }
.added input:not([type=checkbox]), .added select {
background: color-mix(in srgb, var(--ok) 13%, var(--input)); border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); }
tr.removed td, .wrow.removed, .removed .changed { background: color-mix(in srgb, var(--bad) 13%, transparent); }
.removed input:not([type=checkbox]), .removed select { background: color-mix(in srgb, var(--bad) 10%, var(--input));
border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); text-decoration: line-through; }
.removed label, .removed input { opacity: .75; }
.removed button.icon { color: var(--bad); }
.wrow.added .changed, .wrow.removed .changed { background: transparent; }
.seg { display:inline-flex; border:1px solid var(--line); border-radius:6px; overflow:hidden; vertical-align: 1px; margin-left: 10px; }
.seg button { border:0; border-radius:0; padding:2px 10px; font-size:12px; font-weight:400; }
.seg button.on { background: var(--acc); color:#fff; }
.toggle { cursor: pointer; user-select: none; }
/* every watch has its own table; fixed widths keep Seats/Status aligned across them */
table.matches { table-layout: fixed; min-width: 640px; }
table.matches col.c-seats { width: 15rem; } table.matches col.c-status { width: 10.5rem; }
table.matches tr.sum td.seats { white-space: normal; }
.chev { display: inline-block; width: 1.1em; color: var(--muted); }
tr.sum td { font-weight: 600; } tr.sum td.muted { font-weight: 400; }
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';
live.sort.replaceChildren(...[['departure', 'By departure'], ['newest', 'Newest first']].map(([mode, label]) =>
h('button', { class: gmGet('matchSort', 'departure') === mode ? 'on' : null,
onclick: () => { gmSet('matchSort', mode); render(); } }, label)));
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 };
const collapsed = gmGet('collapsed', {}); // per watch, survives browser restarts; expanded unless collapsed
const sortMode = gmGet('matchSort', 'departure');
const sinceOf = (r) => r.since || r.first;
const byDeparture = (a, b) => (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) || (a.date + a.time).localeCompare(b.date + b.time);
const byNewest = (a, b) => sinceOf(b).localeCompare(sinceOf(a)) || byDeparture(a, b);
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] || (sortMode === 'newest' ? byNewest(a, b) : byDeparture(a, b)));
const open = collapsed[w.name] !== true;
const toggle = () => { collapsed[w.name] = open; gmSet('collapsed', collapsed); render(); };
const seatsCell = (r) => h('td', { class: 'seats' },
r.cabin ? `${CABINS[r.cabin]} ×${r.seats}` : '',
r.status !== 'gone' && r.peak > r.seats
? h('span', { class: 'bad', title: `${r.seats} left · ${r.peak - r.seats} taken since ×${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(sinceOf(r))}`);
// summary: bookable seats per cabin, and how many were lost (drops + sold-out flights)
const byCabin = {};
for (const r of rows) {
if (!r.cabin) continue;
const c = (byCabin[r.cabin] ||= { seats: 0, missed: 0 });
if (r.status === 'active') c.seats += r.seats;
c.missed += r.status === 'gone' ? r.peak : r.peak - r.seats;
}
const count = (st) => rows.filter((r) => r.status === st).length;
const latest = rows.filter((r) => r.status === 'active').map(sinceOf).sort().pop();
const summary = h('tr', { class: 'sum toggle', onclick: toggle, title: open ? 'Collapse' : 'Expand' },
h('td', {}, h('span', { class: 'chev' }, open ? '▾' : '▸'),
`${rows.length} flight${rows.length === 1 ? '' : 's'} · ${count('active')} bookable` +
(count('gone') ? ` · ${count('gone')} gone` : '') + (count('unpaired') ? ` · ${count('unpaired')} without return` : '')),
h('td', { class: 'seats' }, Object.keys(CABINS).filter((c) => byCabin[c]).flatMap((c, i) => [
i ? ' · ' : '', `${CABINS[c]} ×${byCabin[c].seats}`,
byCabin[c].missed > 0 ? h('span', { class: 'bad', title: `${byCabin[c].seats} bookable now · ${byCabin[c].missed} taken or sold out` },
` −${byCabin[c].missed}`) : null])),
h('td', { class: 'muted' }, latest ? `since ${when(latest)}` : '–'));
return h('div', { class: 'card', style: 'margin-bottom:10px' },
h('div', { class: 'toggle', onclick: toggle },
h('strong', {}, w.name), ' ',
h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` +
` · ${w.cabins.map((c) => CABINS[c]).join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : ''))),
rows.length
? h('div', { class: 'scroll' }, h('table', { class: 'matches' },
h('colgroup', {}, h('col'), h('col', { class: 'c-seats' }), h('col', { class: 'c-status' })),
h('tr', {}, h('th', {}, 'Flight'), h('th', {}, 'Seats'), h('th', {}, 'Status')),
summary,
open ? 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 || []).map(({ priority, ...n }) => ({ type: 'zulip', url: '', ...n }));
d.watches = (d.watches || []).map(migrateWatch).map((w) => ({
enabled: true, require_both_legs: true, min_seats: 1, max_stops: null, min_nights: null, max_nights: null, ...w,
cabins: (w.cabins || Object.values(CABIN_KEYS)).map(cabinKey),
}));
return d;
};
const canon = (v) => JSON.stringify(v, (k, x) => (x === '' || x === undefined ? null : x));
const saved = normalizeDraft(rawConfig);
const bound = new Map(); // input/cell -> [object, key], for highlighting unsaved fields
const ed = {};
let draft, origOf, removed, dirty = false;
/** The draft without rows marked for removal: what Save stores and JSON shows. */
const effective = () => ({ ...draft, watches: draft.watches.filter((w) => !removed.has(w)),
notify: draft.notify.filter((n) => !removed.has(n)) });
/** Start editing `fresh`; remember which saved row each draft row came from. */
function loadDraft(fresh) {
draft = fresh;
removed = new Set();
origOf = new WeakMap([[draft.settings, saved.settings]]);
draft.watches.forEach((w, i) => {
const o = saved.watches[i];
if (o) origOf.set(w, o);
});
draft.notify.forEach((n, i) => {
const o = saved.notify[i];
if (o) origOf.set(n, o);
});
}
loadDraft(normalizeDraft(rawConfig));
const differs = (obj, key) => { const o = origOf.get(obj); return !o || canon(obj[key]) !== canon(o[key]); };
function changed() {
dirty = canon(effective()) !== canon(saved);
ed.save.disabled = ed.discard.disabled = !dirty;
ed.dirtyNote.textContent = dirty ? 'Unsaved changes' : '';
if (dirty) ed.errors.textContent = '';
for (const [el, [obj, key]] of bound) {
if (!el.isConnected) { bound.delete(el); continue; }
const target = el.matches('td, label, .field') ? el : el.closest('td, .field, label') || el;
target.classList.toggle('changed', differs(obj, key));
}
}
const bind = (obj, key, parse = (v) => v) => ({
$bind: [obj, key],
oninput: (e) => { obj[key] = parse(e.target.type === 'checkbox' ? e.target.checked : e.target.value); changed(); },
});
const optInt = (v) => (v === '' ? null : Number(v));
const codes = (v) => String(v).split(/[\s,;/]+/).map((c) => c.trim().toUpperCase()).filter(Boolean);
/** New rows are dropped at once; saved rows are marked red and only removed on save (click again to undo). */
const removeBtn = (list, obj, what) => h('button', {
class: 'icon', title: removed.has(obj) ? `Keep this ${what}` : `Remove ${what}`,
onclick: () => {
if (!origOf.has(obj)) list.splice(list.indexOf(obj), 1);
else if (removed.has(obj)) removed.delete(obj);
else removed.add(obj);
renderEditor();
} }, removed.has(obj) ? '↺' : '✕');
const rowClass = (obj, extra = '') =>
[extra, removed.has(obj) ? 'removed' : origOf.has(obj) ? '' : 'added'].join(' ').trim() || null;
function watchRow(w) {
const cabinBoxes = Object.entries(CABIN_KEYS).map(([code, key]) => h('label', { class: 'cab' },
h('input', { type: 'checkbox', checked: (w.cabins || []).includes(key), onchange: (e) => {
const set = new Set(w.cabins || []);
if (e.target.checked) set.add(key); else set.delete(key);
w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c));
changed();
} }), ` ${CABINS[code]}`));
const field = (cap, attrs, ...kids) => h('div', { class: 'field', ...attrs }, h('span', { class: 'cap' }, cap), ...kids);
const row = h('div', { class: ['wrow', rowClass(w, w.enabled === false ? 'off' : '')].filter(Boolean).join(' ') },
h('div', { class: 'wline' },
field('On', { class: 'field on', title: 'Enabled' }, h('input', { type: 'checkbox', checked: w.enabled !== false, $bind: [w, 'enabled'],
onchange: (e) => { w.enabled = e.target.checked; row.classList.toggle('off', !w.enabled); changed(); } })),
field('Name', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })),
field('From', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })),
field('To', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })),
field('Earliest', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })),
field('Latest', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })),
h('div', { class: 'rm' }, removeBtn(draft.watches, w, 'watch'))),
h('div', { class: 'wline' },
field('Cabins', { $bind: [w, 'cabins'] }, h('div', { class: 'cabs' }, cabinBoxes)),
field('Seats', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })),
field('Stops', {}, 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)))),
field('Both legs', { class: 'field on', 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') })),
field('Nights', { title: 'Optional stay length, between the two departure dates' }, h('div', { class: 'nights' },
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) })))));
return row;
}
function notifyRow(n) {
return h('tr', { class: rowClass(n) },
h('td', {}, h('select', { ...bind(n, 'type') },
(NOTIFY_TYPES.includes(n.type) ? NOTIFY_TYPES : [n.type, ...NOTIFY_TYPES]).map((t) =>
h('option', { value: t, selected: n.type === t, disabled: !NOTIFY_TYPES.includes(t) },
NOTIFY_TYPES.includes(t) ? t : `${t} (no longer supported)`)))),
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'Webhook URL', ...bind(n, 'url') })),
h('td', {}, removeBtn(draft.notify, n, '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: 'wlist' }, 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: Object.values(CABIN_KEYS), min_seats: 1, max_stops: null,
require_both_legs: true, min_nights: null, max_nights: null });
renderEditor();
} }, '+ Add watch')),
h('h3', {}, 'Notifications'),
h('div', { class: 'scroll' }, h('table', {},
h('tr', {}, ['Type', 'URL', ''].map((c) => h('th', {}, c))),
draft.notify.map(notifyRow))),
h('div', { class: 'btns' },
h('button', { onclick: () => { draft.notify.push({ type: 'zulip', url: '' }); 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))));
changed();
}
// ---- export / import / reset -------------------------------------------------
function exportAll() {
const data = { config: rawConfig, state };
for (const k of PREF_KEYS) { const v = gmGet(k, null); if (v !== null) data[k] = v; }
const file = { app: 'sas-bonus-watch', format: 1, version: typeof GM_info === 'object' ? GM_info.script?.version : undefined,
exported_at: new Date().toISOString(), data };
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' })),
download: `sas-bonus-watch-${todayISO()}.json` });
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
log(dirty ? 'exported (saved config only; unsaved edits are not included)' : 'exported config and data');
}
async function importFile(f) {
let b;
try { b = parseBackup(await f.text()); } catch (e) { alert(`Import failed: ${e.message}`); return; }
const what = b.full
? `the config, ${b.tracked} tracked flight(s) and history from ${b.exportedAt ? new Date(b.exportedAt).toLocaleString('sv-SE') : 'the file'}`
: 'the config only (your current matches and history are kept)';
if (!confirm(`Import ${what}?\n\nThis replaces your current ${b.full ? 'config and data' : 'config'}.`)) return;
frozen = true;
gmSet('config', b.config);
if (b.full) {
gmSet('state', b.state);
for (const k of PREF_KEYS) if (k in b.prefs) gmSet(k, b.prefs[k]);
}
dirty = false;
location.reload();
}
function resetAll() {
if (!confirm('Reset everything?\n\nThis permanently deletes your watches, notification targets, tracked matches and ' +
'history, and starts over with new random example watches.\n\nUse Export first if you want a backup.')) return;
frozen = true;
gmSet('config', null); // next load generates a new example config
gmSet('state', {});
gmSet('collapsed', {});
gmSet('matchSort', 'departure');
gmSet('paused', false);
gmSet('baseline', false);
dirty = false;
location.reload();
}
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.sort = h('span', { class: 'seg', title: 'Sort flights' });
live.pause = h('button', { onclick: () => {
status.paused = !status.paused;
gmSet('paused', status.paused);
if (!status.paused) wake?.();
render();
} });
const importInput = h('input', { type: 'file', accept: '.json,application/json', style: 'display:none',
onchange: (e) => { const f = e.target.files[0]; e.target.value = ''; if (f) importFile(f); } });
ed.tables = h('div');
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : cfgNotice);
ed.dirtyNote = h('span', { class: 'warn small' });
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
try {
validateConfig(effective());
gmSet('config', effective());
dirty = false;
location.reload();
} catch (e) { ed.errors.textContent = e.message; }
} }, 'Save & restart');
ed.discard = h('button', { disabled: true, onclick: () => {
loadDraft(normalizeDraft(rawConfig));
renderEditor();
ed.errors.textContent = '';
} }, '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('span', { class: 'spacer' }),
h('button', { onclick: exportAll, title: 'Download config, matches and history as one file' }, 'Export'),
h('button', { onclick: () => importInput.click(), title: 'Restore from an Export file' }, 'Import'),
importInput,
h('button', { class: 'danger', onclick: resetAll, title: 'Delete everything and start over' }, 'Reset')),
h('h2', {}, 'Current matches', live.sort), 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),
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();
})();