Cabin names, unsaved-change highlight, collapsible matches

- Cabins are Economy / Premium / Business everywhere; the config stores
  economy/premium/business and still accepts AG/AP/AB and Go/Plus
- Config fields that differ from the saved config get a faded yellow
  background, new rows are highlighted in full, removed rows are counted;
  reverting a field clears its highlight and Save disables when clean
- Current matches collapse per watch to a summary row: flight counts,
  bookable seats per cabin with lost seats in red, latest "since";
  open/closed state is remembered

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 08:37:40 +02:00
1 parent f1acd53a9c
commit cb3528d8b0
3 files changed
+136 -55

No files matched your search

+114 -47
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name SAS bonus watch
// @namespace sas-bonus-finder
// @version 1.1.1
// @version 1.2.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
@@ -36,7 +36,13 @@
(function () {
'use strict';
const CABINS = { AG: 'SAS Go', AP: 'SAS Plus', AB: 'SAS Business' };
// 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 DEFAULT_CONFIG = {
settings: {
@@ -59,7 +65,7 @@
destinations: ['HND', 'NRT'],
from: '2026-12-15',
to: '2027-01-10',
cabins: ['AP', 'AB'],
cabins: ['premium', 'business'],
min_seats: 2,
max_stops: 1,
require_both_legs: true,
@@ -128,9 +134,10 @@
const name = String(w.name || '').trim() || `watch ${i + 1}`;
const at = `"${name}"`;
Object.keys(w).filter((k) => !WATCH_KEYS.includes(k)).forEach((k) => err(`${at}: unknown key "${k}"`));
const cabins = (w.cabins || Object.keys(CABINS)).map((c) => String(c).toUpperCase());
if (!cabins.length) err(`${at}: pick at least one cabin`);
cabins.filter((c) => !CABINS[c]).forEach((c) => err(`${at}: unknown cabin "${c}", use AG, AP or AB`));
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 };
@@ -436,7 +443,7 @@
return { runCycle, estimate };
}
const core = { validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
const core = { CABINS, validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
if (typeof module === 'object' && module.exports) { module.exports = core; return; } // node tests
// ================================================================ browser runtime
@@ -607,7 +614,8 @@
const h = (tag, attrs = {}, ...kids) => {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
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);
}
@@ -632,13 +640,20 @@
button.icon { padding: 2px 8px; color: var(--muted); }
button:disabled { opacity: .5; cursor: default; }
table { width:100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
td, th { text-align:left; padding: 4px 6px 4px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
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: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; }
label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; }
tr.off td { opacity: .45; } tr.off td:first-child { opacity: 1; }
.changed, tr.added td { 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, tr.added input:not([type=checkbox]), tr.added select {
background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.toggle { cursor: pointer; user-select: none; }
.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); }
@@ -665,28 +680,51 @@
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; collapsed unless explicitly opened
live.matches.replaceChildren(...(cfg?.watches || []).map((w) => {
const rows = Object.values(state.matches?.[w.name] || {}).map((r) => ({ status: 'active', desc: r.line, peak: r.seats, ...r }))
.sort((a, b) => order[a.status] - order[b.status] || (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) ||
(a.date + a.time).localeCompare(b.date + b.time));
const active = rows.filter((r) => r.status === 'active').length;
const open = collapsed[w.name] === false;
const toggle = () => { collapsed[w.name] = open; gmSet('collapsed', collapsed); render(); };
const seatsCell = (r) => h('td', { class: 'seats' },
r.cabin ? `${CABINS[r.cabin].replace('SAS ', '')} ×${r.seats}` : '',
r.cabin ? `${CABINS[r.cabin]} ×${r.seats}` : '',
r.status !== 'gone' && r.peak > r.seats ? h('span', { class: 'bad', title: `was ×${r.peak}` }, ` −${r.peak - r.seats}`) : null);
const statusCell = (r) => r.status === 'gone'
? h('td', { class: 'bad' }, `gone ${when(r.since)}`)
: r.status === 'unpaired'
? h('td', { class: 'muted', title: 'Still has seats, but no return in your window fits with it' }, 'no matching return')
: h('td', { class: 'muted' }, `since ${when(r.first)}`);
// 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((r) => r.since || r.first).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: 'seats taken or sold out since first seen' }, ` −${byCabin[c].missed}`) : null])),
h('td', { class: 'muted' }, latest ? `since ${when(latest)}` : '–'));
return h('div', { class: 'card', style: 'margin-bottom:10px' },
h('strong', {}, w.name), ' ',
h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` +
` · ${w.cabins.join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : '') +
` · ${active} bookable`),
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', {},
h('tr', {}, h('th', {}, 'Flight'), h('th', {}, 'Seats'), h('th', {}, 'Status')),
rows.map((r) => h('tr', { class: r.status }, h('td', { class: 'desc' }, r.desc), seatsCell(r), statusCell(r)))))
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.'));
}));
@@ -705,44 +743,74 @@
const normalizeDraft = (raw) => {
const d = clone(raw || {});
d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) };
d.notify = d.notify || [];
d.watches = (d.watches || []).map(migrateWatch).map((w) => ({ enabled: true, require_both_legs: true, ...w }));
d.notify = (d.notify || []).map((n) => ({ type: 'ntfy', url: '', priority: '', ...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;
};
let draft = normalizeDraft(rawConfig);
let dirty = false;
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, dirty = false;
const setDirty = (on) => {
dirty = on;
ed.save.disabled = ed.discard.disabled = !on;
ed.dirtyNote.textContent = on ? 'Unsaved changes' : '';
if (on) ed.errors.textContent = '';
};
/** Start editing `fresh`; remember which saved row each draft row came from (by position, or by name after an import). */
function loadDraft(fresh, byName = false) {
draft = fresh;
origOf = new WeakMap([[draft.settings, saved.settings]]);
draft.watches.forEach((w, i) => {
const o = byName ? saved.watches.find((x) => x.name === w.name) : saved.watches[i];
if (o) origOf.set(w, o);
});
draft.notify.forEach((n, i) => {
const o = byName ? saved.notify.find((x) => x.url === n.url) : 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(draft) !== canon(saved);
ed.save.disabled = ed.discard.disabled = !dirty;
const kept = [...draft.watches, ...draft.notify].filter((x) => origOf.has(x)).length;
const removed = saved.watches.length + saved.notify.length - kept;
ed.dirtyNote.textContent = dirty ? `Unsaved changes${removed > 0 ? ` · ${removed} row${removed > 1 ? 's' : ''} removed` : ''}` : '';
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') ? el : el.closest('td, label') || el;
target.classList.toggle('changed', differs(obj, key));
}
}
const bind = (obj, key, parse = (v) => v) => ({
oninput: (e) => { obj[key] = parse(e.target.type === 'checkbox' ? e.target.checked : e.target.value); setDirty(true); },
$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);
const removeBtn = (list, i, title) => h('button', { class: 'icon', title, onclick: () => { list.splice(i, 1); setDirty(true); renderEditor(); } }, '✕');
const removeBtn = (list, i, title) => h('button', { class: 'icon', title, onclick: () => { list.splice(i, 1); renderEditor(); } }, '✕');
function watchRow(w, i) {
const cabinBoxes = Object.entries(CABINS).map(([code, label]) => h('label', { class: 'cab', title: label },
h('input', { type: 'checkbox', checked: (w.cabins || []).includes(code), onchange: (e) => {
const 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(code); else set.delete(code);
w.cabins = Object.keys(CABINS).filter((c) => set.has(c));
setDirty(true);
} }), ` ${label.replace('SAS ', '')}`));
const tr = h('tr', { class: w.enabled === false ? 'off' : null },
h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false,
onchange: (e) => { w.enabled = e.target.checked; tr.className = w.enabled ? '' : 'off'; setDirty(true); } })),
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 tr = h('tr', { class: [w.enabled === false ? 'off' : '', origOf.has(w) ? '' : 'added'].join(' ').trim() || null },
h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, $bind: [w, 'enabled'],
onchange: (e) => { w.enabled = e.target.checked; tr.classList.toggle('off', !w.enabled); changed(); } })),
h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })),
h('td', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })),
h('td', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })),
h('td', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })),
h('td', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })),
h('td', { style: 'white-space:nowrap' }, cabinBoxes),
h('td', { style: 'white-space:nowrap', $bind: [w, 'cabins'] }, cabinBoxes),
h('td', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })),
h('td', {}, h('select', { ...bind(w, 'max_stops', optInt) },
[['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) =>
@@ -758,7 +826,7 @@
}
function notifyRow(n, i) {
return h('tr', {},
return h('tr', { class: origOf.has(n) ? null : 'added' },
h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) =>
h('option', { value: t, selected: n.type === t }, t)))),
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })),
@@ -782,8 +850,8 @@
h('div', { class: 'btns' }, h('button', { onclick: () => {
const today = todayISO();
draft.watches.push({ name: `watch ${draft.watches.length + 1}`, enabled: true, origins: ['ARN'], destinations: [],
from: today, to: addDays(today, 14), cabins: ['AG', 'AP', 'AB'], min_seats: 1, max_stops: null, require_both_legs: true });
setDirty(true);
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')),
@@ -792,7 +860,7 @@
h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))),
draft.notify.map(notifyRow))),
h('div', { class: 'btns' },
h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); setDirty(true); renderEditor(); } },
h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); renderEditor(); } },
'+ Add notification'),
h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }),
' Desktop notifications')),
@@ -802,6 +870,7 @@
numSetting('min_request_interval', 'Seconds between requests (≥ 20)', 20),
numSetting('jitter', '+ random up to', 0),
numSetting('cycle_interval', 'Seconds between sweeps', 60))));
changed();
}
function jsonSection() {
@@ -811,8 +880,7 @@
try {
const parsed = normalizeDraft(JSON.parse(json.value));
validateConfig(parsed);
draft = parsed;
setDirty(true);
loadDraft(parsed, true);
renderEditor();
msg.className = 'small ok';
msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.';
@@ -869,10 +937,9 @@
} catch (e) { ed.errors.textContent = e.message; }
} }, 'Save & restart');
ed.discard = h('button', { disabled: true, onclick: () => {
draft = normalizeDraft(rawConfig);
setDirty(false);
ed.errors.textContent = '';
loadDraft(normalizeDraft(rawConfig));
renderEditor();
ed.errors.textContent = '';
} }, 'Discard changes');
document.body.replaceChildren(h('main', {},