From bb7a0e6a1be41ff1554833003d750f685ca197a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Sivertsson?= Date: Tue, 13 Oct 2026 08:44:16 +0200 Subject: [PATCH] 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 --- README.md | 13 +++--- sas-bonus-watch.user.js | 97 +++++++++++++++++++++++++++++------------ 2 files changed, 75 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2cec57d..5d172ab 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,16 @@ Under the tables, **JSON – export / import / reset** holds the same config as *Download .json* for a backup, *Load file…* or paste + *Apply JSON to tables* to restore, *Reset to example* to start over. Nothing takes effect until you press **Save & restart**. -Fields that differ from the saved config are highlighted in yellow until you save or discard; new rows are -highlighted in full. +Unsaved edits are colour-coded until you save or discard: **yellow** = changed field, **green** = new row, +**red** = row that will be removed on save (click ↺ to keep it). ## Dashboard -- **Current matches** per watch, collapsed to a summary row by default (click the header or the summary to expand): +- **Current matches** per watch, sorted *By departure* or *Newest first* (most recently appeared seats on top). + Click a watch's header or summary row to collapse it; this is remembered across browser restarts: - summary: number of flights, bookable seats per cabin with how many were lost in red (`Economy ×30 −6`), and when the most recent flight appeared - - `Economy ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen + - `Economy ×8 −2` – 8 seats left now; the red number is how many were taken since the highest count seen (10) - red, struck through, *gone 12 okt 13:42* – sold out (or below your seat count); kept for two weeks or until departure - grey, *no matching return* – still has seats, but its return disappeared (with Both legs) - **Recent changes**: `+` new / back, `▲` more seats, `▼` fewer seats, `−` gone, `○` lost its return @@ -103,7 +104,7 @@ Only one tab polls at a time, so opening the dashboard twice does not double the The script header points Tampermonkey at this repo: ``` -// @version 1.2.0 +// @version 1.3.0 // @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 ``` @@ -126,7 +127,7 @@ Requirements: 1. Edit `sas-bonus-watch.user.js`. 2. Run the tests: `node --test` (Node 20+). -3. **Bump `@version`** (e.g. 1.2.0 → 1.2.1). Without this, nobody gets the update. +3. **Bump `@version`** (e.g. 1.3.0 → 1.3.1). Without this, nobody gets the update. 4. Commit and push to `main`. ## Development diff --git a/sas-bonus-watch.user.js b/sas-bonus-watch.user.js index ad88cca..1f9b05b 100644 --- a/sas-bonus-watch.user.js +++ b/sas-bonus-watch.user.js @@ -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),