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:
1 parent
cb3528d8b0
commit
bb7a0e6a1b
2 files changed
+75
-35
No files matched your search
@@ -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
|
||||
|
||||
+68
-29
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user