Compare commits
7
Commits
f1acd53a9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9caff094ac | ||
|
|
06c41615b8 | ||
|
|
05d1d28522 | ||
|
|
ee74ffb110 | ||
|
|
7f5d1bb05f | ||
|
|
bb7a0e6a1b | ||
|
|
cb3528d8b0 |
No files matched your search
@@ -4,10 +4,10 @@ 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 **Zulip**, **Slack**, **Discord**, any **webhook**, and desktop notifications
|
||||||
- Dashboard shows what is bookable now **and what you missed**: seat drops and sold-out flights
|
- Dashboard shows what is bookable now **and what you missed**: seat drops and sold-out flights
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
@@ -25,16 +25,20 @@ browser requests and it keeps working behind sas.se's Cloudflare protection.
|
|||||||
|
|
||||||
## Set up
|
## Set up
|
||||||
|
|
||||||
|
On first start the script creates two example watches from ARN with a random theme, a destination that suits it
|
||||||
|
and dates at its next occurrence – e.g. *Christmas in Rovaniemi* (20–30 Dec), *Getaway weekend in Prague* (a Friday
|
||||||
|
→ Monday a few weeks out), *Ski week in Innsbruck* (sportlov), *Valentine's escape to Paris*. Keep, edit or delete them.
|
||||||
|
|
||||||
In the **Config** section of the dashboard:
|
In the **Config** section of the dashboard:
|
||||||
|
|
||||||
1. **Watches** – one row per search:
|
1. **Watches** – each watch is two lines of fields:
|
||||||
|
|
||||||
| Column | Meaning |
|
| Column | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 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 |
|
||||||
@@ -44,9 +48,9 @@ In the **Config** section of the dashboard:
|
|||||||
|
|
||||||
| Type | URL |
|
| Type | URL |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ntfy` | `https://ntfy.sh/<long-random-topic>`. Install the ntfy app and subscribe to the same topic. Priority sets how loud it is |
|
| `zulip` | Zulip's Slack-compatible webhook: `https://<org>.zulipchat.com/api/v1/external/slack_incoming?api_key=<bot key>&stream=<channel>`. Each watch posts to a topic named after it, unless you add `&topic=…` |
|
||||||
| `discord` | A channel webhook URL |
|
|
||||||
| `slack` | An incoming-webhook URL |
|
| `slack` | An incoming-webhook URL |
|
||||||
|
| `discord` | A channel webhook URL |
|
||||||
| `webhook` | Anything; receives `POST` JSON `{title, body, watch, matches: [...]}` |
|
| `webhook` | Anything; receives `POST` JSON `{title, body, watch, matches: [...]}` |
|
||||||
|
|
||||||
The first time the script posts to a host, Tampermonkey asks you to allow it — choose *Always allow domain*.
|
The first time the script posts to a host, Tampermonkey asks you to allow it — choose *Always allow domain*.
|
||||||
@@ -54,16 +58,28 @@ In the **Config** section of the dashboard:
|
|||||||
3. Press **Save & restart**, then **Test notification**.
|
3. Press **Save & restart**, then **Test notification**.
|
||||||
4. Optional: **Baseline (no alerts)** records what is available right now, so you are only told about new seats.
|
4. Optional: **Baseline (no alerts)** records what is available right now, so you are only told about new seats.
|
||||||
|
|
||||||
### JSON export / import / reset
|
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).
|
||||||
|
|
||||||
Under the tables, **JSON – export / import / reset** holds the same config as JSON:
|
### Export, Import, Reset
|
||||||
*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**.
|
The buttons at the top right:
|
||||||
|
|
||||||
|
- **Export** downloads one `.json` file with everything: the saved config, all tracked matches (including drops and
|
||||||
|
sold-out flights), recent changes, the flight cache, and view settings (collapsed watches, sort order).
|
||||||
|
Unsaved edits in the config tables are not included.
|
||||||
|
- **Import** restores such a file after a confirmation and replaces the current config and data. Config-only `.json`
|
||||||
|
files from older versions are accepted too; they replace just the config and keep your matches and history.
|
||||||
|
- **Reset** deletes everything, after a warning, and starts over with new random example watches. Export first if you
|
||||||
|
want a backup.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
- **Current matches** per watch:
|
- **Current matches** per watch, sorted *By departure* or *Newest first* (most recently appeared seats on top).
|
||||||
- `Go ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen
|
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` – 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
|
- 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
|
||||||
@@ -90,15 +106,15 @@ Only one tab polls at a time, so opening the dashboard twice does not double the
|
|||||||
|
|
||||||
- Runs only while the browser is open and the computer awake.
|
- Runs only while the browser is open and the computer awake.
|
||||||
- Firefox / Chrome may unload background tabs under memory pressure; pinning the tab usually prevents it.
|
- Firefox / Chrome may unload background tabs under memory pressure; pinning the tab usually prevents it.
|
||||||
- Config, matches and history live in the userscript manager's storage on that browser only. Use *Download .json*
|
- Config, matches and history live in the userscript manager's storage on that browser only. Use **Export** /
|
||||||
to move the config elsewhere.
|
**Import** to back up or move everything to another browser.
|
||||||
|
|
||||||
## Auto-update
|
## Auto-update
|
||||||
|
|
||||||
The script header points Tampermonkey at this repo:
|
The script header points Tampermonkey at this repo:
|
||||||
|
|
||||||
```
|
```
|
||||||
// @version 1.1.1
|
// @version 1.5.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 +137,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.5.0 → 1.5.1). Without this, nobody gets the update.
|
||||||
4. Commit and push to `main`.
|
4. Commit and push to `main`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -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,84 @@ 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' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// every award destination SAS listed from ARN on 2026-10-13 (award-finder destinations/v1)
|
||||||
|
const SAS_FROM_ARN = new Set(('AGA ALC ALF AMS AYT ATH ATL BCN BRI BGO BER BIO BLL BHX BOO BLQ BOD BOS BRU BUD CTA CHQ ORD DUB DUS EDI FAO ' +
|
||||||
|
'FLR FRA FUE FAE GZP GDN GVA GOT HAD HAM HAJ EVE HAU HEL INN KLR KKN KRN KRK KRS KSU CPH LCA LPA LIS LHR LYR LAX LLA LUX LYS FNC ' +
|
||||||
|
'MAD AGP MMX MLA MAN RAK MIA MXP LIN BOM MUC NAP EWR JFK NCE OLB OSL PLQ PMO PMI CDG PSA OPO POZ PRG PUY KEF RHO FCO RNB RVN SZG ' +
|
||||||
|
'SFO SEA ICN SVQ SFT SPU SVG STR SDL SCR TLL TLV TFS SKG HND YYZ TOS TRD UME VAA VCE VNO VBY WAW IAD VIE WRO ZRH AGH TKU AAL AES ' +
|
||||||
|
'AAR OSD').split(' '));
|
||||||
|
|
||||||
|
const seeded = (seed) => () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
|
||||||
|
|
||||||
|
test('example destinations are all SAS award destinations from ARN', () => {
|
||||||
|
for (const t of core.THEMES) for (const code of t.places) assert.ok(SAS_FROM_ARN.has(code) && core.PLACES[code], code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('date rules for yearly themes', () => {
|
||||||
|
assert.equal(core.easterSunday(2027), '2027-03-28');
|
||||||
|
assert.equal(core.easterSunday(2028), '2028-04-16');
|
||||||
|
const win = (i, y) => core.THEMES[i].window(y, '2026-10-13', () => 0);
|
||||||
|
assert.deepEqual(win(0, 2026), { from: '2026-12-20', to: '2026-12-30' }); // Christmas
|
||||||
|
assert.deepEqual(win(4, 2027), { from: '2027-02-27', to: '2027-03-07' }); // sportlov, ISO week 9
|
||||||
|
assert.deepEqual(win(6, 2027), { from: '2027-03-25', to: '2027-03-29' }); // Maundy Thursday -> Easter Monday
|
||||||
|
assert.deepEqual(win(7, 2027), { from: '2027-06-24', to: '2027-06-27' }); // around Midsummer Eve, Fri 25 June
|
||||||
|
const weekend = core.THEMES[2].window(2026, '2026-10-13', () => 0);
|
||||||
|
assert.deepEqual(weekend, { from: '2026-11-06', to: '2026-11-09' }); // first Friday ≥ 3 weeks out, through Monday
|
||||||
|
});
|
||||||
|
|
||||||
|
test('example configs are valid, near-future, themed and different each time', () => {
|
||||||
|
const today = '2026-10-13';
|
||||||
|
const names = new Set();
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const cfg = core.exampleConfig(today, seeded(seed));
|
||||||
|
const valid = core.validateConfig(cfg);
|
||||||
|
assert.equal(valid.watches.length, 2);
|
||||||
|
assert.deepEqual(cfg.notify, []);
|
||||||
|
for (const w of cfg.watches) {
|
||||||
|
assert.deepEqual(w.origins, ['ARN']);
|
||||||
|
assert.ok(w.from >= '2026-10-27' && w.from <= '2027-05-11', `${w.name} ${w.from}`);
|
||||||
|
assert.ok(w.to > w.from);
|
||||||
|
assert.ok(w.name.includes(core.PLACES[w.destinations[0]]), w.name);
|
||||||
|
names.add(w.name);
|
||||||
|
}
|
||||||
|
assert.notEqual(cfg.watches[0].name, cfg.watches[1].name);
|
||||||
|
}
|
||||||
|
assert.ok(names.size > 40, `only ${names.size} distinct names`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ntfy targets are rejected with a clear message; zulip is accepted', () => {
|
||||||
|
assert.throws(() => core.validateConfig({ notify: [{ type: 'ntfy', url: 'https://ntfy.sh/x' }], watches: [watch()] }),
|
||||||
|
/ntfy is no longer supported/);
|
||||||
|
assert.equal(core.validateConfig({ notify: [{ type: 'zulip', url: 'https://x.zulipchat.com/api/v1/external/slack_incoming?api_key=k&stream=s' }],
|
||||||
|
watches: [watch()] }).notify[0].type, 'zulip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import reads full exports and older config-only backups, and rejects junk', () => {
|
||||||
|
const config = { notify: [], watches: [watch()] };
|
||||||
|
const state = { matches: { tokyo: { a: {}, b: {} } }, events: [] };
|
||||||
|
const full = core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', format: 1, exported_at: '2026-10-13T08:00:00Z',
|
||||||
|
data: { config, state, collapsed: { tokyo: true }, matchSort: 'newest' } }));
|
||||||
|
assert.equal(full.full, true);
|
||||||
|
assert.equal(full.tracked, 2);
|
||||||
|
assert.deepEqual(full.state, state);
|
||||||
|
assert.deepEqual(full.prefs, { collapsed: { tokyo: true }, matchSort: 'newest' });
|
||||||
|
|
||||||
|
const old = core.parseBackup(JSON.stringify(config));
|
||||||
|
assert.deepEqual([old.full, old.config], [false, config]);
|
||||||
|
|
||||||
|
assert.throws(() => core.parseBackup('{oops'), /not valid JSON/);
|
||||||
|
assert.throws(() => core.parseBackup('{"hello": 1}'), /not a SAS bonus watch export/);
|
||||||
|
assert.throws(() => core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', data: { state } })), /no config/);
|
||||||
|
assert.throws(() => core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', data: { config: { watches: [{ origins: 'X' }] } } })),
|
||||||
|
/not a 3-letter airport code/);
|
||||||
|
});
|
||||||
+415
-158
@@ -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.5.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
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
// @grant GM_setValue
|
// @grant GM_setValue
|
||||||
// @grant GM_xmlhttpRequest
|
// @grant GM_xmlhttpRequest
|
||||||
// @grant GM_notification
|
// @grant GM_notification
|
||||||
// @connect ntfy.sh
|
|
||||||
// @connect discord.com
|
// @connect discord.com
|
||||||
// @connect hooks.slack.com
|
// @connect hooks.slack.com
|
||||||
// @connect *
|
// @connect *
|
||||||
@@ -36,7 +35,15 @@
|
|||||||
(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 NOTIFY_TYPES = ['zulip', 'slack', 'discord', 'webhook'];
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
settings: {
|
settings: {
|
||||||
@@ -48,25 +55,8 @@
|
|||||||
prefilter: true,
|
prefilter: true,
|
||||||
desktop_notifications: true,
|
desktop_notifications: true,
|
||||||
},
|
},
|
||||||
notify: [
|
notify: [],
|
||||||
{ type: 'ntfy', url: 'https://ntfy.sh/CHANGE-ME-to-a-long-random-topic', priority: 'high' },
|
watches: [], // a fresh install gets exampleConfig() instead
|
||||||
],
|
|
||||||
watches: [
|
|
||||||
{
|
|
||||||
name: 'Tokyo over Christmas',
|
|
||||||
enabled: true,
|
|
||||||
origins: ['ARN', 'CPH'],
|
|
||||||
destinations: ['HND', 'NRT'],
|
|
||||||
from: '2026-12-15',
|
|
||||||
to: '2027-01-10',
|
|
||||||
cabins: ['AP', 'AB'],
|
|
||||||
min_seats: 2,
|
|
||||||
max_stops: 1,
|
|
||||||
require_both_legs: true,
|
|
||||||
min_nights: 10,
|
|
||||||
max_nights: 21,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------- dates
|
// ---------------------------------------------------------------- dates
|
||||||
@@ -85,6 +75,114 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- example watches
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A fresh install (and "New random example") gets a few themed watches from ARN with a destination that
|
||||||
|
* suits the name and dates at the next occurrence of the theme. Every destination below is an SAS award
|
||||||
|
* destination from ARN (award-finder destinations list, October 2026).
|
||||||
|
*/
|
||||||
|
const PLACES = {
|
||||||
|
AGA: 'Agadir', AGP: 'Málaga', AMS: 'Amsterdam', ATH: 'Athens', BCN: 'Barcelona', BER: 'Berlin', BOO: 'Bodø',
|
||||||
|
BUD: 'Budapest', CDG: 'Paris', DUB: 'Dublin', EDI: 'Edinburgh', EWR: 'New York', FCO: 'Rome', FLR: 'Florence',
|
||||||
|
FNC: 'Madeira', FUE: 'Fuerteventura', GVA: 'Geneva', HND: 'Tokyo', ICN: 'Seoul', INN: 'Innsbruck', JFK: 'New York',
|
||||||
|
KEF: 'Reykjavik', KKN: 'Kirkenes', KLR: 'Kalmar', KRK: 'Kraków', KRN: 'Kiruna', LAX: 'Los Angeles', LCA: 'Larnaca',
|
||||||
|
LHR: 'London', LIS: 'Lisbon', LPA: 'Gran Canaria', LYR: 'Svalbard', MIA: 'Miami', MLA: 'Malta', NCE: 'Nice',
|
||||||
|
OPO: 'Porto', OSD: 'Åre', PRG: 'Prague', RAK: 'Marrakech', RVN: 'Rovaniemi', SCR: 'Sälen', SFO: 'San Francisco',
|
||||||
|
SVQ: 'Seville', SZG: 'Salzburg', TFS: 'Tenerife', TOS: 'Tromsø', VBY: 'Visby', VCE: 'Venice', VIE: 'Vienna',
|
||||||
|
YYZ: 'Toronto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iso = (y, m, d) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const weekday = (d) => new Date(d + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
|
||||||
|
const nextWeekday = (d, wd) => addDays(d, (wd - weekday(d) + 7) % 7);
|
||||||
|
|
||||||
|
function easterSunday(y) { // anonymous Gregorian algorithm
|
||||||
|
const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4), e = b % 4;
|
||||||
|
const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30;
|
||||||
|
const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||||
|
return iso(y, Math.floor((h + l - 7 * m + 114) / 31), ((h + l - 7 * m + 114) % 31) + 1);
|
||||||
|
}
|
||||||
|
const isoWeekMonday = (y, week) => { // Monday of ISO week `week`
|
||||||
|
const jan4 = iso(y, 1, 4);
|
||||||
|
return addDays(jan4, -((weekday(jan4) + 6) % 7) + (week - 1) * 7);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** window(year, today, rnd) -> {from, to}; yearly themes try this year, then next. */
|
||||||
|
const THEMES = [
|
||||||
|
{ names: ['Christmas in {city}', 'Visiting Santa in {city}', 'Hiding from Christmas shopping in {city}', 'Mulled wine in {city}'],
|
||||||
|
places: ['RVN', 'TOS', 'KRN', 'KEF', 'LYR', 'VIE', 'SZG'], nights: [4, 9],
|
||||||
|
window: (y) => ({ from: iso(y, 12, 20), to: iso(y, 12, 30) }) },
|
||||||
|
{ names: ["New Year's Eve in {city}", 'Fireworks and regrets in {city}', 'Counting down in {city}'],
|
||||||
|
places: ['KEF', 'BER', 'LHR', 'EDI', 'PRG', 'BCN', 'JFK'], nights: [2, 4],
|
||||||
|
window: (y) => ({ from: iso(y, 12, 29), to: iso(y + 1, 1, 2) }) },
|
||||||
|
{ names: ['Getaway weekend in {city}', '48 hours in {city}', 'A very long lunch in {city}', 'Pretending to be a local in {city}'],
|
||||||
|
places: ['BCN', 'PRG', 'FCO', 'CDG', 'LHR', 'BER', 'AMS', 'LIS', 'BUD', 'KRK', 'VIE', 'EDI', 'DUB', 'VCE', 'FLR', 'OPO'],
|
||||||
|
nights: [2, 3],
|
||||||
|
window: (y, today, rnd) => { // a Friday 3-10 weeks from now, through Monday
|
||||||
|
const fri = addDays(nextWeekday(addDays(today, 21), 5), 7 * Math.floor(rnd() * 8));
|
||||||
|
return { from: fri, to: addDays(fri, 3) };
|
||||||
|
} },
|
||||||
|
{ names: ['Escaping the dark to {city}', 'Vitamin D hunt in {city}', 'Thawing out in {city}'],
|
||||||
|
places: ['TFS', 'LPA', 'FUE', 'FNC', 'AGP', 'AGA', 'RAK', 'MLA', 'LCA', 'MIA'], nights: [6, 12],
|
||||||
|
window: (y, today, rnd) => { const from = addDays(iso(y, 1, 10), Math.floor(rnd() * 50)); return { from, to: addDays(from, 14) }; } },
|
||||||
|
{ names: ['Ski week in {city}', 'Après-ski practice in {city}', 'Falling down mountains in {city}'],
|
||||||
|
places: ['INN', 'SZG', 'GVA', 'OSD', 'SCR'], nights: [5, 8],
|
||||||
|
window: (y) => { const mon = isoWeekMonday(y, 9); return { from: addDays(mon, -2), to: addDays(mon, 6) }; } }, // Stockholm sportlov
|
||||||
|
{ names: ["Valentine's escape to {city}", 'Romantic disaster in {city}', 'Candlelit dinner in {city}'],
|
||||||
|
places: ['CDG', 'VCE', 'FCO', 'LIS', 'FLR', 'NCE'], nights: [2, 3], seats: 2,
|
||||||
|
window: (y) => ({ from: iso(y, 2, 12), to: iso(y, 2, 16) }) },
|
||||||
|
{ names: ['Easter egg hunt in {city}', 'Easter witch flight to {city}', 'Chocolate eggs in {city}'],
|
||||||
|
places: ['FCO', 'FLR', 'SVQ', 'ATH', 'LIS', 'AGP'], nights: [3, 4],
|
||||||
|
window: (y) => { const sun = easterSunday(y); return { from: addDays(sun, -3), to: addDays(sun, 1) }; } },
|
||||||
|
{ names: ['Midsummer in {city}', 'Dancing like small frogs in {city}', 'Midnight sun in {city}'],
|
||||||
|
places: ['VBY', 'KLR', 'KRN', 'TOS', 'BOO', 'LYR'], nights: [2, 3],
|
||||||
|
window: (y) => { const eve = nextWeekday(iso(y, 6, 19), 5); return { from: addDays(eve, -1), to: addDays(eve, 2) }; } },
|
||||||
|
{ names: ['Bucket list: {city}', 'Finally going to {city}', 'Long-haul daydream: {city}'],
|
||||||
|
places: ['HND', 'ICN', 'LAX', 'SFO', 'EWR', 'MIA', 'YYZ'], nights: [10, 21], cabins: ['economy', 'premium', 'business'],
|
||||||
|
window: (y, today, rnd) => { const from = addDays(today, 60 + Math.floor(rnd() * 120)); return { from, to: addDays(from, 21) }; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pick = (list, rnd) => list[Math.floor(rnd() * list.length)];
|
||||||
|
|
||||||
|
/** Next window of a theme that starts at least two weeks from today. */
|
||||||
|
function themeWindow(theme, today, rnd) {
|
||||||
|
const y = Number(today.slice(0, 4));
|
||||||
|
for (const year of [y, y + 1]) {
|
||||||
|
const w = theme.window(year, today, rnd);
|
||||||
|
if (w.from >= addDays(today, 14)) return w;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleWatch(theme, today, rnd = Math.random) {
|
||||||
|
const code = pick(theme.places, rnd);
|
||||||
|
const w = themeWindow(theme, today, rnd);
|
||||||
|
return {
|
||||||
|
name: pick(theme.names, rnd).replace('{city}', PLACES[code]),
|
||||||
|
enabled: true,
|
||||||
|
origins: ['ARN'],
|
||||||
|
destinations: [code],
|
||||||
|
from: w.from,
|
||||||
|
to: w.to,
|
||||||
|
cabins: theme.cabins || ['economy', 'premium'],
|
||||||
|
min_seats: theme.seats || 1,
|
||||||
|
max_stops: null,
|
||||||
|
require_both_legs: true,
|
||||||
|
min_nights: theme.nights[0],
|
||||||
|
max_nights: theme.nights[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A config with `count` themed watches, chosen among themes that come up within ~7 months. */
|
||||||
|
function exampleConfig(today = todayISO(), rnd = Math.random, count = 2) {
|
||||||
|
const soon = THEMES.map((t) => ({ t, w: themeWindow(t, today, () => 0) }))
|
||||||
|
.filter((x) => x.w && x.w.from <= addDays(today, 210)).map((x) => x.t);
|
||||||
|
const chosen = [];
|
||||||
|
while (chosen.length < count && soon.length) chosen.push(soon.splice(Math.floor(rnd() * soon.length), 1)[0]);
|
||||||
|
return { settings: { ...DEFAULT_CONFIG.settings }, notify: [], watches: chosen.map((t) => exampleWatch(t, today, rnd)) };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- config
|
// ---------------------------------------------------------------- config
|
||||||
|
|
||||||
const blank = (v) => v == null || v === '';
|
const blank = (v) => v == null || v === '';
|
||||||
@@ -128,9 +226,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 };
|
||||||
@@ -157,9 +256,10 @@
|
|||||||
|
|
||||||
const notify = (raw.notify || []).filter(Boolean).map((n, i) => {
|
const notify = (raw.notify || []).filter(Boolean).map((n, i) => {
|
||||||
const type = String(n.type || '').toLowerCase();
|
const type = String(n.type || '').toLowerCase();
|
||||||
if (!['ntfy', 'discord', 'slack', 'webhook'].includes(type)) err(`notification ${i + 1}: unknown type "${n.type}"`);
|
if (type === 'ntfy') err(`notification ${i + 1}: ntfy is no longer supported, use zulip, slack, discord or webhook`);
|
||||||
|
else if (!NOTIFY_TYPES.includes(type)) err(`notification ${i + 1}: unknown type "${n.type}"`);
|
||||||
if (!/^https?:\/\//.test(n.url || '')) err(`notification ${i + 1}: URL must start with http(s)://`);
|
if (!/^https?:\/\//.test(n.url || '')) err(`notification ${i + 1}: URL must start with http(s)://`);
|
||||||
return { type, url: n.url, priority: n.priority };
|
return { type, url: n.url };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errors.length) throw new Error(errors.join('\n'));
|
if (errors.length) throw new Error(errors.join('\n'));
|
||||||
@@ -436,7 +536,31 @@
|
|||||||
return { runCycle, estimate };
|
return { runCycle, estimate };
|
||||||
}
|
}
|
||||||
|
|
||||||
const core = { validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
|
/**
|
||||||
|
* Reads an Export file ({app, data: {config, state, ...prefs}}) or, for older backups, a plain config.
|
||||||
|
* Throws with a readable message when the file can't be used.
|
||||||
|
*/
|
||||||
|
function parseBackup(text) {
|
||||||
|
// old ntfy targets are ignored at runtime (removed in 1.4.0), so they shouldn't block an import either
|
||||||
|
const check = (c) => validateConfig({ ...c, notify: (c.notify || []).filter((n) => String(n?.type).toLowerCase() !== 'ntfy') });
|
||||||
|
let d;
|
||||||
|
try { d = JSON.parse(text); } catch { throw new Error('the file is not valid JSON'); }
|
||||||
|
if (d && d.app === 'sas-bonus-watch' && d.data) {
|
||||||
|
const { config, state, ...prefs } = d.data;
|
||||||
|
if (!config) throw new Error('the file has no config');
|
||||||
|
check(config);
|
||||||
|
if (state != null && (typeof state !== 'object' || Array.isArray(state))) throw new Error('the file has invalid match data');
|
||||||
|
const tracked = Object.values(state?.matches || {}).reduce((n, m) => n + Object.keys(m || {}).length, 0);
|
||||||
|
return { full: true, config, state: state || {}, prefs, exportedAt: d.exported_at, tracked };
|
||||||
|
}
|
||||||
|
if (d && typeof d === 'object' && (d.watches || d.settings || d.notify)) {
|
||||||
|
check(d);
|
||||||
|
return { full: false, config: d };
|
||||||
|
}
|
||||||
|
throw new Error('this is not a SAS bonus watch export');
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = { parseBackup, CABINS, THEMES, PLACES, exampleConfig, exampleWatch, easterSunday, 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
|
||||||
@@ -457,10 +581,23 @@
|
|||||||
|
|
||||||
// -- config & state
|
// -- config & state
|
||||||
let cfg, cfgError = null;
|
let cfg, cfgError = null;
|
||||||
const rawConfig = gmGet('config', DEFAULT_CONFIG);
|
let rawConfig = gmGet('config', null);
|
||||||
try { cfg = validateConfig(rawConfig); } catch (e) { cfgError = e.message; }
|
if (!rawConfig) { // first run: start from a random themed example, saved so it doesn't change on every reload
|
||||||
|
rawConfig = exampleConfig();
|
||||||
|
gmSet('config', rawConfig);
|
||||||
|
}
|
||||||
|
// ntfy was removed in 1.4.0: ignore old ntfy targets at runtime instead of stopping, and ask to remove them
|
||||||
|
const isNtfy = (n) => String(n?.type).toLowerCase() === 'ntfy';
|
||||||
|
const legacyNtfy = (rawConfig.notify || []).filter(isNtfy).length;
|
||||||
|
const cfgNotice = legacyNtfy
|
||||||
|
? `${legacyNtfy} ntfy notification target(s) are ignored: ntfy is no longer supported. Remove the row and Save.` : '';
|
||||||
|
try {
|
||||||
|
cfg = validateConfig({ ...rawConfig, notify: (rawConfig.notify || []).filter((n) => !isNtfy(n)) });
|
||||||
|
} catch (e) { cfgError = e.message; }
|
||||||
const state = gmGet('state', {});
|
const state = gmGet('state', {});
|
||||||
const saveState = () => gmSet('state', state);
|
let frozen = false; // set while importing/resetting so a sweep in progress can't write old data back
|
||||||
|
const saveState = () => { if (!frozen) gmSet('state', state); };
|
||||||
|
const PREF_KEYS = ['collapsed', 'matchSort', 'paused'];
|
||||||
|
|
||||||
// -- notifications
|
// -- notifications
|
||||||
function gmPost(url, body, headers = {}) {
|
function gmPost(url, body, headers = {}) {
|
||||||
@@ -473,16 +610,15 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const NTFY_PRIORITY = { min: 1, low: 2, default: 3, high: 4, urgent: 5, max: 5 };
|
|
||||||
async function sendOne(n, title, body, data) {
|
async function sendOne(n, title, body, data) {
|
||||||
if (n.type === 'ntfy') {
|
if (n.type === 'zulip') {
|
||||||
const u = new URL(n.url); // https://ntfy.sh/topic -> publish JSON to the server root
|
// Zulip's Slack-compatible webhook (…/api/v1/external/slack_incoming?api_key=…&stream=…): Slack *bold* is
|
||||||
await gmPost(u.origin + '/', { topic: u.pathname.replace(/^\/+/, ''), title, message: body, tags: ['airplane'],
|
// converted; "channel" becomes the topic unless the URL sets &topic=
|
||||||
priority: NTFY_PRIORITY[n.priority] || +n.priority || 3 });
|
await gmPost(n.url, { text: `*${title}*\n${body}`, channel: data.watch || 'SAS bonus watch' });
|
||||||
} else if (n.type === 'discord') {
|
|
||||||
await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) });
|
|
||||||
} else if (n.type === 'slack') {
|
} else if (n.type === 'slack') {
|
||||||
await gmPost(n.url, { text: `*${title}*\n${body}` });
|
await gmPost(n.url, { text: `*${title}*\n${body}` });
|
||||||
|
} else if (n.type === 'discord') {
|
||||||
|
await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) });
|
||||||
} else {
|
} else {
|
||||||
await gmPost(n.url, { title, body, ...data });
|
await gmPost(n.url, { title, body, ...data });
|
||||||
}
|
}
|
||||||
@@ -570,6 +706,7 @@
|
|||||||
async function mainLoop() {
|
async function mainLoop() {
|
||||||
if (cfgError) { status.phase = 'config error'; ui.render(); return; }
|
if (cfgError) { status.phase = 'config error'; ui.render(); return; }
|
||||||
if (!cfg.watches.length) { status.phase = 'no active watches'; ui.render(); return; }
|
if (!cfg.watches.length) { status.phase = 'no active watches'; ui.render(); return; }
|
||||||
|
if (cfgNotice) log(cfgNotice, 'warn');
|
||||||
const watcher = createWatcher({ cfg, api, state, save: saveState, notify, log });
|
const watcher = createWatcher({ cfg, api, state, save: saveState, notify, log });
|
||||||
log(`budget: ${watcher.estimate()}; ≥${cfg.settings.min_request_interval} s between requests, sweep every ${cfg.settings.cycle_interval} s`);
|
log(`budget: ${watcher.estimate()}; ≥${cfg.settings.min_request_interval} s between requests, sweep every ${cfg.settings.cycle_interval} s`);
|
||||||
setInterval(() => { if (status.phase !== 'other tab') holdLock(); }, 30000);
|
setInterval(() => { if (status.phase !== 'other tab') holdLock(); }, 30000);
|
||||||
@@ -607,7 +744,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);
|
||||||
}
|
}
|
||||||
@@ -630,15 +768,50 @@
|
|||||||
button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; }
|
button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; }
|
||||||
button.primary { background: var(--acc); border-color: var(--acc); color: #fff; }
|
button.primary { background: var(--acc); border-color: var(--acc); color: #fff; }
|
||||||
button.icon { padding: 2px 8px; color: var(--muted); }
|
button.icon { padding: 2px 8px; color: var(--muted); }
|
||||||
|
button.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); }
|
||||||
|
.btns .spacer { flex: 1; }
|
||||||
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: 17em; } 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; }
|
.wlist { border-top: 1px solid var(--line); }
|
||||||
|
.wrow { padding: 8px 4px; border-bottom: 1px solid var(--line); }
|
||||||
|
.wline { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: flex-end; }
|
||||||
|
.wline + .wline { margin-top: 4px; padding-left: 34px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 2px; padding: 2px 4px; border-radius: 4px; }
|
||||||
|
.field > .cap { font-size: 11px; color: var(--muted); }
|
||||||
|
.field.on { align-items: center; } .field.on > input { margin: 4px 0; }
|
||||||
|
.cabs, .nights { display: flex; align-items: center; gap: 2px; white-space: nowrap; min-height: 27px; }
|
||||||
|
.wrow .rm { margin-left: auto; align-self: center; }
|
||||||
|
.wrow.off .field:not(:first-child), .wrow.off .wline + .wline { opacity: .45; }
|
||||||
|
/* 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 {
|
||||||
|
background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||||||
|
tr.added td, .wrow.added, .added .changed { background: color-mix(in srgb, var(--ok) 13%, transparent); }
|
||||||
|
.added input:not([type=checkbox]), .added 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, .wrow.removed, .removed .changed { background: color-mix(in srgb, var(--bad) 13%, transparent); }
|
||||||
|
.removed input:not([type=checkbox]), .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; }
|
||||||
|
.removed label, .removed input { opacity: .75; }
|
||||||
|
.removed button.icon { color: var(--bad); }
|
||||||
|
.wrow.added .changed, .wrow.removed .changed { background: transparent; }
|
||||||
|
.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; }
|
||||||
|
/* every watch has its own table; fixed widths keep Seats/Status aligned across them */
|
||||||
|
table.matches { table-layout: fixed; min-width: 640px; }
|
||||||
|
table.matches col.c-seats { width: 15rem; } table.matches col.c-status { width: 10.5rem; }
|
||||||
|
table.matches tr.sum td.seats { white-space: normal; }
|
||||||
|
.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); }
|
||||||
@@ -662,31 +835,63 @@
|
|||||||
h('div', { class: 'muted' },
|
h('div', { class: 'muted' },
|
||||||
`Last sweep ${t(status.lastCycle)} · next ${t(status.nextCycle)} · ${client.requests} request(s) since load · keep this tab open`));
|
`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.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 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, 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) => {
|
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] || (sortMode === 'newest' ? byNewest(a, b) : byDeparture(a, b)));
|
||||||
(a.date + a.time).localeCompare(b.date + b.time));
|
const open = collapsed[w.name] !== true;
|
||||||
const active = rows.filter((r) => r.status === 'active').length;
|
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: `${r.seats} left · ${r.peak - r.seats} taken since ×${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(sinceOf(r))}`);
|
||||||
|
|
||||||
|
// 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(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: `${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' },
|
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', { class: 'matches' },
|
||||||
|
h('colgroup', {}, h('col'), h('col', { class: 'c-seats' }), h('col', { class: 'c-status' })),
|
||||||
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,66 +910,111 @@
|
|||||||
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(({ priority, ...n }) => ({ type: 'zulip', url: '', ...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, 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)) });
|
||||||
|
|
||||||
const setDirty = (on) => {
|
/** Start editing `fresh`; remember which saved row each draft row came from. */
|
||||||
dirty = on;
|
function loadDraft(fresh) {
|
||||||
ed.save.disabled = ed.discard.disabled = !on;
|
draft = fresh;
|
||||||
ed.dirtyNote.textContent = on ? 'Unsaved changes' : '';
|
removed = new Set();
|
||||||
if (on) ed.errors.textContent = '';
|
origOf = new WeakMap([[draft.settings, saved.settings]]);
|
||||||
};
|
draft.watches.forEach((w, i) => {
|
||||||
|
const o = saved.watches[i];
|
||||||
|
if (o) origOf.set(w, o);
|
||||||
|
});
|
||||||
|
draft.notify.forEach((n, i) => {
|
||||||
|
const o = 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(effective()) !== canon(saved);
|
||||||
|
ed.save.disabled = ed.discard.disabled = !dirty;
|
||||||
|
ed.dirtyNote.textContent = dirty ? 'Unsaved changes' : '';
|
||||||
|
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, .field') ? el : el.closest('td, .field, 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(); } }, '✕');
|
/** 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(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 field = (cap, attrs, ...kids) => h('div', { class: 'field', ...attrs }, h('span', { class: 'cap' }, cap), ...kids);
|
||||||
h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false,
|
const row = h('div', { class: ['wrow', rowClass(w, w.enabled === false ? 'off' : '')].filter(Boolean).join(' ') },
|
||||||
onchange: (e) => { w.enabled = e.target.checked; tr.className = w.enabled ? '' : 'off'; setDirty(true); } })),
|
h('div', { class: 'wline' },
|
||||||
h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })),
|
field('On', { class: 'field on', title: 'Enabled' }, h('input', { type: 'checkbox', checked: w.enabled !== false, $bind: [w, 'enabled'],
|
||||||
h('td', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })),
|
onchange: (e) => { w.enabled = e.target.checked; row.classList.toggle('off', !w.enabled); changed(); } })),
|
||||||
h('td', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })),
|
field('Name', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })),
|
||||||
h('td', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })),
|
field('From', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })),
|
||||||
h('td', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })),
|
field('To', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })),
|
||||||
h('td', { style: 'white-space:nowrap' }, cabinBoxes),
|
field('Earliest', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })),
|
||||||
h('td', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })),
|
field('Latest', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })),
|
||||||
h('td', {}, h('select', { ...bind(w, 'max_stops', optInt) },
|
h('div', { class: 'rm' }, removeBtn(draft.watches, w, 'watch'))),
|
||||||
[['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) =>
|
h('div', { class: 'wline' },
|
||||||
h('option', { value: v, selected: String(w.max_stops ?? '') === v }, l)))),
|
field('Cabins', { $bind: [w, 'cabins'] }, h('div', { class: 'cabs' }, cabinBoxes)),
|
||||||
h('td', { title: 'Only alert when an outbound and a return both fit in the window' },
|
field('Seats', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })),
|
||||||
h('input', { type: 'checkbox', checked: w.require_both_legs !== false, ...bind(w, 'require_both_legs') })),
|
field('Stops', {}, h('select', { ...bind(w, 'max_stops', optInt) },
|
||||||
h('td', { style: 'white-space:nowrap' },
|
[['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) =>
|
||||||
h('input', { class: 'num', type: 'number', min: 0, placeholder: 'min', value: w.min_nights ?? '', ...bind(w, 'min_nights', optInt) }),
|
h('option', { value: v, selected: String(w.max_stops ?? '') === v }, l)))),
|
||||||
' – ',
|
field('Both legs', { class: 'field on', title: 'Only alert when an outbound and a return both fit in the window' },
|
||||||
h('input', { class: 'num', type: 'number', min: 0, placeholder: 'max', value: w.max_nights ?? '', ...bind(w, 'max_nights', optInt) })),
|
h('input', { type: 'checkbox', checked: w.require_both_legs !== false, ...bind(w, 'require_both_legs') })),
|
||||||
h('td', {}, removeBtn(draft.watches, i, 'Remove watch')));
|
field('Nights', { title: 'Optional stay length, between the two departure dates' }, h('div', { class: 'nights' },
|
||||||
return tr;
|
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) })))));
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyRow(n, i) {
|
function notifyRow(n) {
|
||||||
return h('tr', {},
|
return h('tr', { class: rowClass(n) },
|
||||||
h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) =>
|
h('td', {}, h('select', { ...bind(n, 'type') },
|
||||||
h('option', { value: t, selected: n.type === t }, t)))),
|
(NOTIFY_TYPES.includes(n.type) ? NOTIFY_TYPES : [n.type, ...NOTIFY_TYPES]).map((t) =>
|
||||||
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })),
|
h('option', { value: t, selected: n.type === t, disabled: !NOTIFY_TYPES.includes(t) },
|
||||||
h('td', {}, h('select', { title: 'Priority (ntfy only)', ...bind(n, 'priority') }, ['', 'low', 'default', 'high', 'urgent'].map((p) =>
|
NOTIFY_TYPES.includes(t) ? t : `${t} (no longer supported)`)))),
|
||||||
h('option', { value: p, selected: (n.priority || '') === p }, p || '–')))),
|
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'Webhook URL', ...bind(n, 'url') })),
|
||||||
h('td', {}, removeBtn(draft.notify, i, 'Remove notification')));
|
h('td', {}, removeBtn(draft.notify, n, 'notification')));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
@@ -775,24 +1025,21 @@
|
|||||||
h('h3', {}, 'Watches'),
|
h('h3', {}, 'Watches'),
|
||||||
h('div', { class: 'small muted', style: 'margin-bottom:6px' },
|
h('div', { class: 'small muted', style: 'margin-bottom:6px' },
|
||||||
'Finds outbound and return flights departing between Earliest and Latest. Airports are comma-separated codes; every From × To pair is checked.'),
|
'Finds outbound and return flights departing between Earliest and Latest. Airports are comma-separated codes; every From × To pair is checked.'),
|
||||||
h('div', { class: 'scroll' }, h('table', {},
|
h('div', { class: 'wlist' }, draft.watches.map(watchRow)),
|
||||||
h('tr', {}, ['On', 'Name', 'From', 'To', 'Earliest', 'Latest', 'Cabins', 'Seats', 'Stops', 'Both legs', 'Nights', '']
|
|
||||||
.map((c) => h('th', {}, c))),
|
|
||||||
draft.watches.map(watchRow))),
|
|
||||||
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')),
|
||||||
|
|
||||||
h('h3', {}, 'Notifications'),
|
h('h3', {}, 'Notifications'),
|
||||||
h('div', { class: 'scroll' }, h('table', {},
|
h('div', { class: 'scroll' }, h('table', {},
|
||||||
h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))),
|
h('tr', {}, ['Type', 'URL', ''].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: 'zulip', url: '' }); 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,45 +1049,51 @@
|
|||||||
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() {
|
// ---- export / import / reset -------------------------------------------------
|
||||||
const json = h('textarea', { spellcheck: 'false' });
|
function exportAll() {
|
||||||
const msg = h('div', { class: 'small' });
|
const data = { config: rawConfig, state };
|
||||||
const apply = () => {
|
for (const k of PREF_KEYS) { const v = gmGet(k, null); if (v !== null) data[k] = v; }
|
||||||
try {
|
const file = { app: 'sas-bonus-watch', format: 1, version: typeof GM_info === 'object' ? GM_info.script?.version : undefined,
|
||||||
const parsed = normalizeDraft(JSON.parse(json.value));
|
exported_at: new Date().toISOString(), data };
|
||||||
validateConfig(parsed);
|
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' })),
|
||||||
draft = parsed;
|
download: `sas-bonus-watch-${todayISO()}.json` });
|
||||||
setDirty(true);
|
a.click();
|
||||||
renderEditor();
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
msg.className = 'small ok';
|
log(dirty ? 'exported (saved config only; unsaved edits are not included)' : 'exported config and data');
|
||||||
msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.';
|
}
|
||||||
} catch (e) { msg.className = 'small err'; msg.textContent = e.message; }
|
|
||||||
};
|
async function importFile(f) {
|
||||||
const file = h('input', { type: 'file', accept: '.json,application/json', style: 'display:none', onchange: async (e) => {
|
let b;
|
||||||
const f = e.target.files[0];
|
try { b = parseBackup(await f.text()); } catch (e) { alert(`Import failed: ${e.message}`); return; }
|
||||||
if (f) { json.value = await f.text(); apply(); }
|
const what = b.full
|
||||||
e.target.value = '';
|
? `the config, ${b.tracked} tracked flight(s) and history from ${b.exportedAt ? new Date(b.exportedAt).toLocaleString('sv-SE') : 'the file'}`
|
||||||
} });
|
: 'the config only (your current matches and history are kept)';
|
||||||
return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(draft, null, 2); } },
|
if (!confirm(`Import ${what}?\n\nThis replaces your current ${b.full ? 'config and data' : 'config'}.`)) return;
|
||||||
h('summary', {}, 'JSON – export / import / reset'),
|
frozen = true;
|
||||||
h('div', { class: 'small muted', style: 'margin-bottom:6px' },
|
gmSet('config', b.config);
|
||||||
'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.'),
|
if (b.full) {
|
||||||
json,
|
gmSet('state', b.state);
|
||||||
h('div', { class: 'btns' },
|
for (const k of PREF_KEYS) if (k in b.prefs) gmSet(k, b.prefs[k]);
|
||||||
h('button', { onclick: apply }, 'Apply JSON to tables'),
|
}
|
||||||
h('button', { onclick: () => {
|
dirty = false;
|
||||||
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' })),
|
location.reload();
|
||||||
download: `sas-bonus-watch-${todayISO()}.json` });
|
}
|
||||||
a.click();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
function resetAll() {
|
||||||
} }, 'Download .json'),
|
if (!confirm('Reset everything?\n\nThis permanently deletes your watches, notification targets, tracked matches and ' +
|
||||||
h('button', { onclick: () => file.click() }, 'Load file…'),
|
'history, and starts over with new random example watches.\n\nUse Export first if you want a backup.')) return;
|
||||||
file,
|
frozen = true;
|
||||||
h('button', { onclick: () => { if (confirm('Replace the config with the example?')) { json.value = JSON.stringify(DEFAULT_CONFIG, null, 2); apply(); } } },
|
gmSet('config', null); // next load generates a new example config
|
||||||
'Reset to example')),
|
gmSet('state', {});
|
||||||
msg);
|
gmSet('collapsed', {});
|
||||||
|
gmSet('matchSort', 'departure');
|
||||||
|
gmSet('paused', false);
|
||||||
|
gmSet('baseline', false);
|
||||||
|
dirty = false;
|
||||||
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
@@ -850,6 +1103,7 @@
|
|||||||
live.matches = h('div');
|
live.matches = h('div');
|
||||||
live.events = h('pre');
|
live.events = h('pre');
|
||||||
live.log = h('pre');
|
live.log = h('pre');
|
||||||
|
live.sort = h('span', { class: 'seg', title: 'Sort flights' });
|
||||||
live.pause = h('button', { onclick: () => {
|
live.pause = h('button', { onclick: () => {
|
||||||
status.paused = !status.paused;
|
status.paused = !status.paused;
|
||||||
gmSet('paused', status.paused);
|
gmSet('paused', status.paused);
|
||||||
@@ -857,22 +1111,23 @@
|
|||||||
render();
|
render();
|
||||||
} });
|
} });
|
||||||
|
|
||||||
|
const importInput = h('input', { type: 'file', accept: '.json,application/json', style: 'display:none',
|
||||||
|
onchange: (e) => { const f = e.target.files[0]; e.target.value = ''; if (f) importFile(f); } });
|
||||||
ed.tables = h('div');
|
ed.tables = h('div');
|
||||||
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : '');
|
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : cfgNotice);
|
||||||
ed.dirtyNote = h('span', { class: 'warn small' });
|
ed.dirtyNote = h('span', { class: 'warn small' });
|
||||||
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
|
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
|
||||||
try {
|
try {
|
||||||
validateConfig(draft);
|
validateConfig(effective());
|
||||||
gmSet('config', draft);
|
gmSet('config', effective());
|
||||||
dirty = false;
|
dirty = false;
|
||||||
location.reload();
|
location.reload();
|
||||||
} 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', {},
|
||||||
@@ -884,14 +1139,16 @@
|
|||||||
? 'test notification sent' : 'test notification failed') }, 'Test notification'),
|
? 'test notification sent' : 'test notification failed') }, 'Test notification'),
|
||||||
h('button', { onclick: () => { gmSet('baseline', true); log('next sweep records current seats without alerting'); wake?.(); } },
|
h('button', { onclick: () => { gmSet('baseline', true); log('next sweep records current seats without alerting'); wake?.(); } },
|
||||||
'Baseline (no alerts)'),
|
'Baseline (no alerts)'),
|
||||||
h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } },
|
h('span', { class: 'spacer' }),
|
||||||
'Reset state')),
|
h('button', { onclick: exportAll, title: 'Download config, matches and history as one file' }, 'Export'),
|
||||||
h('h2', {}, 'Current matches'), live.matches,
|
h('button', { onclick: () => importInput.click(), title: 'Restore from an Export file' }, 'Import'),
|
||||||
|
importInput,
|
||||||
|
h('button', { class: 'danger', onclick: resetAll, title: 'Delete everything and start over' }, 'Reset')),
|
||||||
|
h('h2', {}, 'Current matches', live.sort), live.matches,
|
||||||
h('h2', {}, 'Config'),
|
h('h2', {}, 'Config'),
|
||||||
h('div', { class: 'card' }, ed.tables,
|
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.errors,
|
ed.errors),
|
||||||
jsonSection()),
|
|
||||||
h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events),
|
h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events),
|
||||||
h('h2', {}, 'Log'), h('div', { class: 'card' }, live.log)));
|
h('h2', {}, 'Log'), h('div', { class: 'card' }, live.log)));
|
||||||
window.addEventListener('beforeunload', (e) => { if (dirty) e.preventDefault(); });
|
window.addEventListener('beforeunload', (e) => { if (dirty) e.preventDefault(); });
|
||||||
|
|||||||
Reference in new issue
Block a user