- 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>
1164 lines
63 KiB
JavaScript
1164 lines
63 KiB
JavaScript
// ==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();
|
||
})();
|