Random themed example watches, Zulip instead of ntfy

- A fresh install generates two example watches from ARN: a themed,
  funny name (Christmas, New Year, getaway weekend, sun escape, ski week,
  Valentine's, Easter, Midsummer, bucket list), a fitting SAS award
  destination and dates at the theme's next occurrence; saved on first
  run. "New random example" regenerates the watches and keeps targets.
- Notifications: ntfy and its priority column removed (ntfy entries get
  a clear validation error); new zulip type for Zulip's Slack-compatible
  webhook, with one topic per watch
- Wider name field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Måns SivertssonandClaude Opus 5.5 committed 2026-10-13 09:01:14 +02:00
1 parent 05d1d28522
commit 06c41615b8
3 files changed
+202 -49

No files matched your search

+139 -42
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name SAS bonus watch
// @namespace sas-bonus-finder
// @version 1.3.1
// @version 1.4.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
@@ -13,7 +13,6 @@
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @connect ntfy.sh
// @connect discord.com
// @connect hooks.slack.com
// @connect *
@@ -44,6 +43,8 @@
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',
@@ -54,25 +55,8 @@
prefilter: true,
desktop_notifications: true,
},
notify: [
{ type: 'ntfy', url: 'https://ntfy.sh/CHANGE-ME-to-a-long-random-topic', priority: 'high' },
],
watches: [
{
name: 'Tokyo over Christmas',
enabled: true,
origins: ['ARN', 'CPH'],
destinations: ['HND', 'NRT'],
from: '2026-12-15',
to: '2027-01-10',
cabins: ['premium', 'business'],
min_seats: 2,
max_stops: 1,
require_both_legs: true,
min_nights: 10,
max_nights: 21,
},
],
notify: [],
watches: [], // a fresh install gets exampleConfig() instead
};
// ---------------------------------------------------------------- dates
@@ -91,6 +75,114 @@
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 === '';
@@ -164,9 +256,10 @@
const notify = (raw.notify || []).filter(Boolean).map((n, i) => {
const type = String(n.type || '').toLowerCase();
if (!['ntfy', 'discord', 'slack', 'webhook'].includes(type)) err(`notification ${i + 1}: unknown type "${n.type}"`);
if (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, priority: n.priority };
return { type, url: n.url };
});
if (errors.length) throw new Error(errors.join('\n'));
@@ -443,7 +536,7 @@
return { runCycle, estimate };
}
const core = { CABINS, validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
const core = { 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
@@ -464,7 +557,11 @@
// -- config & state
let cfg, cfgError = null;
const rawConfig = gmGet('config', DEFAULT_CONFIG);
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);
}
try { cfg = validateConfig(rawConfig); } catch (e) { cfgError = e.message; }
const state = gmGet('state', {});
const saveState = () => gmSet('state', state);
@@ -480,16 +577,15 @@
}));
}
const NTFY_PRIORITY = { min: 1, low: 2, default: 3, high: 4, urgent: 5, max: 5 };
async function sendOne(n, title, body, data) {
if (n.type === 'ntfy') {
const u = new URL(n.url); // https://ntfy.sh/topic -> publish JSON to the server root
await gmPost(u.origin + '/', { topic: u.pathname.replace(/^\/+/, ''), title, message: body, tags: ['airplane'],
priority: NTFY_PRIORITY[n.priority] || +n.priority || 3 });
} else if (n.type === 'discord') {
await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) });
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 });
}
@@ -644,7 +740,7 @@
th { font-weight: 600; color: var(--muted); font-size: 12px; white-space: nowrap; }
input, select { font: inherit; font-size: 13px; color: var(--fg); background: var(--input); border: 1px solid var(--line); border-radius: 5px; padding: 3px 5px; }
input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--acc); vertical-align: -3px; }
input.code { width: 8.5em; text-transform: uppercase; } input.name { width: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; }
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); }
@@ -778,7 +874,7 @@
const normalizeDraft = (raw) => {
const d = clone(raw || {});
d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) };
d.notify = (d.notify || []).map((n) => ({ type: 'ntfy', url: '', priority: '', ...n }));
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),
@@ -877,11 +973,9 @@
function notifyRow(n) {
return h('tr', { class: rowClass(n) },
h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) =>
h('td', {}, h('select', { ...bind(n, 'type') }, NOTIFY_TYPES.map((t) =>
h('option', { value: t, selected: n.type === t }, t)))),
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })),
h('td', {}, h('select', { title: 'Priority (ntfy only)', ...bind(n, 'priority') }, ['', 'low', 'default', 'high', 'urgent'].map((p) =>
h('option', { value: p, selected: (n.priority || '') === p }, p || '–')))),
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'Webhook URL', ...bind(n, 'url') })),
h('td', {}, removeBtn(draft.notify, n, 'notification')));
}
@@ -904,10 +998,10 @@
h('h3', {}, 'Notifications'),
h('div', { class: 'scroll' }, h('table', {},
h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))),
h('tr', {}, ['Type', 'URL', ''].map((c) => h('th', {}, c))),
draft.notify.map(notifyRow))),
h('div', { class: 'btns' },
h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); renderEditor(); } },
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')),
@@ -953,8 +1047,11 @@
} }, 'Download .json'),
h('button', { onclick: () => file.click() }, 'Load file…'),
file,
h('button', { onclick: () => { if (confirm('Replace the config with the example?')) { json.value = JSON.stringify(DEFAULT_CONFIG, null, 2); apply(); } } },
'Reset to example')),
h('button', { onclick: () => {
if (!confirm('Replace the watches with a new random example? Your notification targets are kept.')) return;
json.value = JSON.stringify({ ...exampleConfig(), notify: effective().notify }, null, 2);
apply();
} }, 'New random example')),
msg);
}