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:
1 parent
05d1d28522
commit
06c41615b8
3 files changed
+202
-49
No files matched your search
+139
-42
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user