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

+11 -6
View File
@@ -4,7 +4,7 @@ A Tampermonkey / Violentmonkey userscript that watches SAS EuroBonus award avail
seats you care about appear. It runs inside your own browser, so requests to sas.se are ordinary same-origin seats you care about appear. It runs inside your own browser, so requests to sas.se are ordinary same-origin
browser requests and it keeps working behind sas.se's Cloudflare protection. browser requests and it keeps working behind sas.se's Cloudflare protection.
- Any number of **watches**: origins, destinations, one travel window, cabins (Go / Plus / Business), seats, max stops - Any number of **watches**: origins, destinations, one travel window, cabins (Economy / Premium / Business), seats, max stops
- Finds **outbound and return** flights inside the window; optionally only alerts when both legs exist, with min/max nights - Finds **outbound and return** flights inside the window; optionally only alerts when both legs exist, with min/max nights
- **Gentle on sas.se**: about one request a minute, a full sweep at most every 15 minutes, backs off when refused - **Gentle on sas.se**: about one request a minute, a full sweep at most every 15 minutes, backs off when refused
- Alerts via **ntfy** (phone push), **Discord**, **Slack**, any **webhook**, and desktop notifications - Alerts via **ntfy** (phone push), **Discord**, **Slack**, any **webhook**, and desktop notifications
@@ -34,7 +34,7 @@ In the **Config** section of the dashboard:
| On | Pause a watch without deleting it | | On | Pause a watch without deleting it |
| From / To | Airport codes, comma-separated (`ARN, CPH`). Every From × To pair is checked | | From / To | Airport codes, comma-separated (`ARN, CPH`). Every From × To pair is checked |
| Earliest / Latest | Travel window. Outbound **and** return flights departing in it are found | | Earliest / Latest | Travel window. Outbound **and** return flights departing in it are found |
| Cabins | Go (AG), Plus (AP), Business (AB) | | Cabins | Economy, Premium (premium economy), Business |
| Seats | Seats needed **on the same flight** | | Seats | Seats needed **on the same flight** |
| Stops | Any, direct only, or at most 1 / 2 stops | | Stops | Any, direct only, or at most 1 / 2 stops |
| Both legs | Only alert when an outbound has a later return in the window (default on). Off = alert for either direction on its own | | Both legs | Only alert when an outbound has a later return in the window (default on). Off = alert for either direction on its own |
@@ -60,10 +60,15 @@ 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, *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**. *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.
## Dashboard ## Dashboard
- **Current matches** per watch: - **Current matches** per watch, collapsed to a summary row by default (click the header or the summary to expand):
- `Go ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen - 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
- red, struck through, *gone 12 okt 13:42* – sold out (or below your seat count); kept for two weeks or until departure - 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) - 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 - **Recent changes**: `+` new / back, `▲` more seats, `▼` fewer seats, `−` gone, `○` lost its return
@@ -98,7 +103,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: The script header points Tampermonkey at this repo:
``` ```
// @version 1.1.1 // @version 1.2.0
// @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js // @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 // @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
``` ```
@@ -121,7 +126,7 @@ Requirements:
1. Edit `sas-bonus-watch.user.js`. 1. Edit `sas-bonus-watch.user.js`.
2. Run the tests: `node --test` (Node 20+). 2. Run the tests: `node --test` (Node 20+).
3. **Bump `@version`** (e.g. 1.1.1 → 1.1.2). Without this, nobody gets the update. 3. **Bump `@version`** (e.g. 1.2.0 → 1.2.1). Without this, nobody gets the update.
4. Commit and push to `main`. 4. Commit and push to `main`.
## Development ## Development
+11 -2
View File
@@ -50,7 +50,7 @@ test('new seats notify once, unchanged calendar uses cache', async () => {
const { w, calls, sent } = setup([watch()], baseResponses()); const { w, calls, sent } = setup([watch()], baseResponses());
assert.equal(await w.runCycle(), 2); assert.equal(await w.runCycle(), 2);
assert.equal(sent.length, 1); assert.equal(sent.length, 1);
assert.match(sent[0].body, /SAS Plus ×1/); assert.match(sent[0].body, /Premium ×1/);
assert.match(sent[0].body, /OUT ARN→HND Wed 09 Dec 06:00→Thu 10 Dec 07:55, 1 stop/); assert.match(sent[0].body, /OUT ARN→HND Wed 09 Dec 06:00→Thu 10 Dec 07:55, 1 stop/);
assert.deepEqual(calls, ['cal ARN HND', 'routes ARN HND 2026-12-09']); // 12-08 has only AG -> never fetched assert.deepEqual(calls, ['cal ARN HND', 'routes ARN HND 2026-12-09']); // 12-08 has only AG -> never fetched
@@ -167,7 +167,7 @@ test('seat drops are tracked against the peak, gone matches stay listed in red',
const e = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP']; const e = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP'];
assert.deepEqual([e.status, e.seats, e.peak], ['active', 8, 10]); assert.deepEqual([e.status, e.seats, e.peak], ['active', 8, 10]);
assert.equal(state.events[0].kind, 'down'); assert.equal(state.events[0].kind, 'down');
assert.match(state.events[0].line, /SAS Plus ×10 → ×8/); assert.match(state.events[0].line, /Premium ×10 → ×8/);
assert.equal(sent.length, 1); // a drop is shown, not alerted assert.equal(sent.length, 1); // a drop is shown, not alerted
r['cal ARN HND'] = calendarPayload([], []); r['cal ARN HND'] = calendarPayload([], []);
@@ -209,3 +209,12 @@ test('a leg whose return disappeared is marked unpaired, not gone', async () =>
const st = Object.fromEntries(Object.entries(state.matches.tokyo).map(([k, e]) => [k.split('|')[1], e.status])); const st = Object.fromEntries(Object.entries(state.matches.tokyo).map(([k, e]) => [k.split('|')[1], e.status]));
assert.deepEqual(st, { 'SK1415-20261209-ARN-CPH': 'unpaired', 'SK1417-20261209-ARN-CPH': 'unpaired', 'SK984-20261220-HND-CPH': 'gone' }); assert.deepEqual(st, { 'SK1415-20261209-ARN-CPH': 'unpaired', 'SK1417-20261209-ARN-CPH': 'unpaired', 'SK984-20261220-HND-CPH': 'gone' });
}); });
test('cabins are configured by name; old codes and SAS names still work', () => {
const cabinsOf = (cabins) => core.validateConfig({ watches: [watch({ cabins })] }).watches[0].cabins;
assert.deepEqual(cabinsOf(['business', 'Economy']), ['AG', 'AB']);
assert.deepEqual(cabinsOf(['AP', 'go', 'Plus']), ['AG', 'AP']);
assert.deepEqual(cabinsOf(undefined), ['AG', 'AP', 'AB']);
assert.throws(() => cabinsOf(['first']), /unknown cabin "first", use economy, premium or business/);
assert.deepEqual(core.CABINS, { AG: 'Economy', AP: 'Premium', AB: 'Business' });
});
+114 -47
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name SAS bonus watch // @name SAS bonus watch
// @namespace sas-bonus-finder // @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. // @description Watches SAS EuroBonus award seats from your own browser and alerts you when matching seats appear.
// @author Måns Sivertsson // @author Måns Sivertsson
// @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch // @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch
@@ -36,7 +36,13 @@
(function () { (function () {
'use strict'; '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 = { const DEFAULT_CONFIG = {
settings: { settings: {
@@ -59,7 +65,7 @@
destinations: ['HND', 'NRT'], destinations: ['HND', 'NRT'],
from: '2026-12-15', from: '2026-12-15',
to: '2027-01-10', to: '2027-01-10',
cabins: ['AP', 'AB'], cabins: ['premium', 'business'],
min_seats: 2, min_seats: 2,
max_stops: 1, max_stops: 1,
require_both_legs: true, require_both_legs: true,
@@ -128,9 +134,10 @@
const name = String(w.name || '').trim() || `watch ${i + 1}`; const name = String(w.name || '').trim() || `watch ${i + 1}`;
const at = `"${name}"`; const at = `"${name}"`;
Object.keys(w).filter((k) => !WATCH_KEYS.includes(k)).forEach((k) => err(`${at}: unknown key "${k}"`)); 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()); const rawCabins = w.cabins || Object.values(CABIN_KEYS);
if (!cabins.length) err(`${at}: pick at least one cabin`); if (!rawCabins.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`)); 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)`); 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"`); else if (w.to < w.from) err(`${at}: "to" is before "from"`);
const window = { from: w.from, to: w.to }; const window = { from: w.from, to: w.to };
@@ -436,7 +443,7 @@
return { runCycle, estimate }; 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 if (typeof module === 'object' && module.exports) { module.exports = core; return; } // node tests
// ================================================================ browser runtime // ================================================================ browser runtime
@@ -607,7 +614,8 @@
const h = (tag, attrs = {}, ...kids) => { const h = (tag, attrs = {}, ...kids) => {
const el = document.createElement(tag); const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) { 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 (k === 'checked' || k === 'disabled' || k === 'value' || k === 'selected') el[k] = v;
else if (v != null && v !== false) el.setAttribute(k, v === true ? '' : 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.icon { padding: 2px 8px; color: var(--muted); }
button:disabled { opacity: .5; cursor: default; } button:disabled { opacity: .5; cursor: default; }
table { width:100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } 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; } 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, 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[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: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; }
label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; } label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; }
tr.off td { opacity: .45; } tr.off td:first-child { opacity: 1; } 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; } 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.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); } 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 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 order = { active: 0, unpaired: 1, gone: 2 };
const collapsed = gmGet('collapsed', {}); // per watch; collapsed unless explicitly opened
live.matches.replaceChildren(...(cfg?.watches || []).map((w) => { 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 })) 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) || .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)); (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' }, 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); 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' const statusCell = (r) => r.status === 'gone'
? h('td', { class: 'bad' }, `gone ${when(r.since)}`) ? h('td', { class: 'bad' }, `gone ${when(r.since)}`)
: r.status === 'unpaired' : 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', 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(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' }, return h('div', { class: 'card', style: 'margin-bottom:10px' },
h('strong', {}, w.name), ' ', h('div', { class: 'toggle', onclick: toggle },
h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` + h('strong', {}, w.name), ' ',
` · ${w.cabins.join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : '') + h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` +
` · ${active} bookable`), ` · ${w.cabins.map((c) => CABINS[c]).join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : ''))),
rows.length rows.length
? h('div', { class: 'scroll' }, h('table', {}, ? h('div', { class: 'scroll' }, h('table', {},
h('tr', {}, h('th', {}, 'Flight'), h('th', {}, 'Seats'), h('th', {}, 'Status')), 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.')); : h('div', { class: 'muted', style: 'margin-top:6px' }, 'No matching seats right now.'));
})); }));
@@ -705,44 +743,74 @@
const normalizeDraft = (raw) => { const normalizeDraft = (raw) => {
const d = clone(raw || {}); const d = clone(raw || {});
d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) }; d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) };
d.notify = d.notify || []; 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, ...w })); 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; return d;
}; };
let draft = normalizeDraft(rawConfig); const canon = (v) => JSON.stringify(v, (k, x) => (x === '' || x === undefined ? null : x));
let dirty = false; const saved = normalizeDraft(rawConfig);
const bound = new Map(); // input/cell -> [object, key], for highlighting unsaved fields
const ed = {}; const ed = {};
let draft, origOf, dirty = false;
const setDirty = (on) => { /** Start editing `fresh`; remember which saved row each draft row came from (by position, or by name after an import). */
dirty = on; function loadDraft(fresh, byName = false) {
ed.save.disabled = ed.discard.disabled = !on; draft = fresh;
ed.dirtyNote.textContent = on ? 'Unsaved changes' : ''; origOf = new WeakMap([[draft.settings, saved.settings]]);
if (on) ed.errors.textContent = ''; 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) => ({ 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 optInt = (v) => (v === '' ? null : Number(v));
const codes = (v) => String(v).split(/[\s,;/]+/).map((c) => c.trim().toUpperCase()).filter(Boolean); 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) { function watchRow(w, i) {
const cabinBoxes = Object.entries(CABINS).map(([code, label]) => h('label', { class: 'cab', title: label }, const cabinBoxes = Object.entries(CABIN_KEYS).map(([code, key]) => h('label', { class: 'cab' },
h('input', { type: 'checkbox', checked: (w.cabins || []).includes(code), onchange: (e) => { h('input', { type: 'checkbox', checked: (w.cabins || []).includes(key), onchange: (e) => {
const set = new Set(w.cabins || []); const set = new Set(w.cabins || []);
if (e.target.checked) set.add(code); else set.delete(code); if (e.target.checked) set.add(key); else set.delete(key);
w.cabins = Object.keys(CABINS).filter((c) => set.has(c)); w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c));
setDirty(true); changed();
} }), ` ${label.replace('SAS ', '')}`)); } }), ` ${CABINS[code]}`));
const tr = h('tr', { class: w.enabled === false ? 'off' : null }, 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, h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, $bind: [w, 'enabled'],
onchange: (e) => { w.enabled = e.target.checked; tr.className = w.enabled ? '' : 'off'; setDirty(true); } })), 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: '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.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', { 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.from || '', ...bind(w, 'from') })),
h('td', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })), 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('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) }, h('td', {}, h('select', { ...bind(w, 'max_stops', optInt) },
[['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) => [['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) =>
@@ -758,7 +826,7 @@
} }
function notifyRow(n, i) { 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('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) =>
h('option', { value: t, selected: n.type === t }, 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', { 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: () => { h('div', { class: 'btns' }, h('button', { onclick: () => {
const today = todayISO(); const today = todayISO();
draft.watches.push({ name: `watch ${draft.watches.length + 1}`, enabled: true, origins: ['ARN'], destinations: [], 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 }); from: today, to: addDays(today, 14), cabins: Object.values(CABIN_KEYS), min_seats: 1, max_stops: null,
setDirty(true); require_both_legs: true, min_nights: null, max_nights: null });
renderEditor(); renderEditor();
} }, '+ Add watch')), } }, '+ Add watch')),
@@ -792,7 +860,7 @@
h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))), h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))),
draft.notify.map(notifyRow))), draft.notify.map(notifyRow))),
h('div', { class: 'btns' }, 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'), '+ Add notification'),
h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }), h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }),
' Desktop notifications')), ' Desktop notifications')),
@@ -802,6 +870,7 @@
numSetting('min_request_interval', 'Seconds between requests (≥ 20)', 20), numSetting('min_request_interval', 'Seconds between requests (≥ 20)', 20),
numSetting('jitter', '+ random up to', 0), numSetting('jitter', '+ random up to', 0),
numSetting('cycle_interval', 'Seconds between sweeps', 60)))); numSetting('cycle_interval', 'Seconds between sweeps', 60))));
changed();
} }
function jsonSection() { function jsonSection() {
@@ -811,8 +880,7 @@
try { try {
const parsed = normalizeDraft(JSON.parse(json.value)); const parsed = normalizeDraft(JSON.parse(json.value));
validateConfig(parsed); validateConfig(parsed);
draft = parsed; loadDraft(parsed, true);
setDirty(true);
renderEditor(); renderEditor();
msg.className = 'small ok'; msg.className = 'small ok';
msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.'; msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.';
@@ -869,10 +937,9 @@
} catch (e) { ed.errors.textContent = e.message; } } catch (e) { ed.errors.textContent = e.message; }
} }, 'Save & restart'); } }, 'Save & restart');
ed.discard = h('button', { disabled: true, onclick: () => { ed.discard = h('button', { disabled: true, onclick: () => {
draft = normalizeDraft(rawConfig); loadDraft(normalizeDraft(rawConfig));
setDirty(false);
ed.errors.textContent = '';
renderEditor(); renderEditor();
ed.errors.textContent = '';
} }, 'Discard changes'); } }, 'Discard changes');
document.body.replaceChildren(h('main', {}, document.body.replaceChildren(h('main', {},