Colour-coded config edits, match sorting, expanded by default

- Unsaved edits: yellow = changed, green = added, red = removed on save;
  removed rows stay visible with an undo button, with a legend by Save
- Current matches: sort by departure or newest first (remembered);
  expanded by default, collapsed state remembered per watch
- Status "since" is when a flight last became bookable; tooltips spell
  out seats left vs taken

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:44:16 +02:00
1 parent cb3528d8b0
commit bb7a0e6a1b
2 files changed
+75 -35

No files matched your search

+68 -29
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name SAS bonus watch
// @namespace sas-bonus-finder
// @version 1.2.0
// @version 1.3.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
@@ -647,10 +647,23 @@
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); }
/* 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, tr.added input:not([type=checkbox]), tr.added select {
.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, tr.added td.changed { background: color-mix(in srgb, var(--ok) 13%, transparent); }
tr.added input:not([type=checkbox]), tr.added select, tr.added .changed > input:not([type=checkbox]), tr.added .changed > 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, tr.removed td.changed { background: color-mix(in srgb, var(--bad) 13%, transparent); }
tr.removed input:not([type=checkbox]), tr.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; }
tr.removed label, tr.removed input { opacity: .75; }
tr.removed button.icon { color: var(--bad); }
.legend span { margin-right: 12px; } .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:-1px; }
.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; }
.chev { display: inline-block; width: 1.1em; color: var(--muted); }
tr.sum td { font-weight: 600; } tr.sum td.muted { font-weight: 400; }
@@ -677,24 +690,31 @@
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; collapsed unless explicitly opened
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] || (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) ||
(a.date + a.time).localeCompare(b.date + b.time));
const open = collapsed[w.name] === false;
.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: `was ×${r.peak}` }, ` −${r.peak - r.seats}`) : null);
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(r.first)}`);
: h('td', { class: 'muted' }, `since ${when(sinceOf(r))}`);
// summary: bookable seats per cabin, and how many were lost (drops + sold-out flights)
const byCabin = {};
@@ -705,14 +725,15 @@
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 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: 'seats taken or sold out since first seen' }, ` −${byCabin[c].missed}`) : null])),
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' },
@@ -754,11 +775,15 @@
const saved = normalizeDraft(rawConfig);
const bound = new Map(); // input/cell -> [object, key], for highlighting unsaved fields
const ed = {};
let draft, origOf, dirty = false;
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 (by position, or by name after an import). */
function loadDraft(fresh, byName = false) {
draft = fresh;
removed = new Set();
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];
@@ -774,11 +799,10 @@
const differs = (obj, key) => { const o = origOf.get(obj); return !o || canon(obj[key]) !== canon(o[key]); };
function changed() {
dirty = canon(draft) !== canon(saved);
dirty = canon(effective()) !== 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` : ''}` : '';
ed.dirtyNote.textContent = dirty ? 'Unsaved changes' : '';
ed.legend.hidden = !dirty;
if (dirty) ed.errors.textContent = '';
for (const [el, [obj, key]] of bound) {
if (!el.isConnected) { bound.delete(el); continue; }
@@ -792,9 +816,19 @@
});
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); renderEditor(); } }, '✕');
/** 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, i) {
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 || []);
@@ -802,7 +836,7 @@
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 },
const tr = h('tr', { class: rowClass(w, w.enabled === false ? 'off' : '') },
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') })),
@@ -821,18 +855,18 @@
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) })),
h('td', {}, removeBtn(draft.watches, i, 'Remove watch')));
h('td', {}, removeBtn(draft.watches, w, 'watch')));
return tr;
}
function notifyRow(n, i) {
return h('tr', { class: origOf.has(n) ? null : 'added' },
function notifyRow(n) {
return h('tr', { class: rowClass(n) },
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') })),
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', {}, removeBtn(draft.notify, i, 'Remove notification')));
h('td', {}, removeBtn(draft.notify, n, 'notification')));
}
function renderEditor() {
@@ -891,7 +925,7 @@
if (f) { json.value = await f.text(); apply(); }
e.target.value = '';
} });
return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(draft, null, 2); } },
return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(effective(), null, 2); } },
h('summary', {}, 'JSON – export / import / reset'),
h('div', { class: 'small muted', style: 'margin-bottom:6px' },
'The config as currently edited above. Edit or paste JSON and apply it to the tables, or save it to a file as a backup.'),
@@ -899,7 +933,7 @@
h('div', { class: 'btns' },
h('button', { onclick: apply }, 'Apply JSON to tables'),
h('button', { onclick: () => {
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' })),
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(effective(), null, 2)], { type: 'application/json' })),
download: `sas-bonus-watch-${todayISO()}.json` });
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
@@ -918,6 +952,7 @@
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);
@@ -928,10 +963,14 @@
ed.tables = h('div');
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : '');
ed.dirtyNote = h('span', { class: 'warn small' });
ed.legend = h('span', { class: 'legend small muted', hidden: true },
h('span', {}, h('i', { style: 'background:color-mix(in srgb, var(--warn) 45%, transparent)' }), 'changed'),
h('span', {}, h('i', { style: 'background:color-mix(in srgb, var(--ok) 45%, transparent)' }), 'added'),
h('span', {}, h('i', { style: 'background:color-mix(in srgb, var(--bad) 45%, transparent)' }), 'removed on save'));
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
try {
validateConfig(draft);
gmSet('config', draft);
validateConfig(effective());
gmSet('config', effective());
dirty = false;
location.reload();
} catch (e) { ed.errors.textContent = e.message; }
@@ -953,10 +992,10 @@
'Baseline (no alerts)'),
h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } },
'Reset state')),
h('h2', {}, 'Current matches'), live.matches,
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),
h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote, ed.legend),
ed.errors,
jsonSection()),
h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events),