From ee74ffb1104e2a550331935f47e3f210f4fbb755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Sivertsson?= Date: Tue, 13 Oct 2026 08:51:11 +0200 Subject: [PATCH] Two-line watch editor, aligned match columns - Watches config: each watch on two wrapping lines of labelled fields instead of one wide table, so there is no horizontal scroll - Match tables share fixed Seats/Status column widths, so columns line up across watches and do not shift when collapsing or expanding Co-Authored-By: Claude Opus 5.5 --- sas-bonus-watch.user.js | 85 ++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/sas-bonus-watch.user.js b/sas-bonus-watch.user.js index 9897a44..54d6eff 100644 --- a/sas-bonus-watch.user.js +++ b/sas-bonus-watch.user.js @@ -646,24 +646,38 @@ 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; } 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 { + .changed input:not([type=checkbox]), .changed select { background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); } - tr.added td, tr.added td.changed { background: color-mix(in srgb, var(--ok) 13%, transparent); } - tr.added input:not([type=checkbox]), tr.added select, tr.added .changed > input:not([type=checkbox]), tr.added .changed > select { + 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, tr.removed td.changed { background: color-mix(in srgb, var(--bad) 13%, transparent); } - tr.removed input:not([type=checkbox]), tr.removed select { background: color-mix(in srgb, var(--bad) 10%, var(--input)); + 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; } - tr.removed label, tr.removed input { opacity: .75; } - tr.removed button.icon { color: var(--bad); } + .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; } @@ -741,7 +755,8 @@ h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` + ` · ${w.cabins.map((c) => CABINS[c]).join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : ''))), 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')), summary, open ? rows.map((r) => h('tr', { class: r.status }, h('td', { class: 'desc' }, r.desc), seatsCell(r), statusCell(r))) : [])) @@ -804,7 +819,7 @@ 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; + const target = el.matches('td, label, .field') ? el : el.closest('td, .field, label') || el; target.classList.toggle('changed', differs(obj, key)); } } @@ -834,27 +849,30 @@ w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c)); changed(); } }), ` ${CABINS[code]}`)); - const tr = h('tr', { class: rowClass(w, w.enabled === false ? 'off' : '') }, - h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, $bind: [w, 'enabled'], - onchange: (e) => { w.enabled = e.target.checked; tr.classList.toggle('off', !w.enabled); changed(); } })), - h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })), - 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', { type: 'date', value: w.from || '', ...bind(w, 'from') })), - h('td', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })), - 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('select', { ...bind(w, 'max_stops', optInt) }, - [['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) => - h('option', { value: v, selected: String(w.max_stops ?? '') === v }, l)))), - h('td', { title: 'Only alert when an outbound and a return both fit in the window' }, - h('input', { type: 'checkbox', checked: w.require_both_legs !== false, ...bind(w, 'require_both_legs') })), - h('td', { style: 'white-space:nowrap' }, - h('input', { class: 'num', type: 'number', min: 0, placeholder: 'min', value: w.min_nights ?? '', ...bind(w, 'min_nights', optInt) }), - ' – ', - h('input', { class: 'num', type: 'number', min: 0, placeholder: 'max', value: w.max_nights ?? '', ...bind(w, 'max_nights', optInt) })), - h('td', {}, removeBtn(draft.watches, w, 'watch'))); - return tr; + const field = (cap, attrs, ...kids) => h('div', { class: 'field', ...attrs }, h('span', { class: 'cap' }, cap), ...kids); + const row = h('div', { class: ['wrow', rowClass(w, w.enabled === false ? 'off' : '')].filter(Boolean).join(' ') }, + h('div', { class: 'wline' }, + field('On', { class: 'field on', title: 'Enabled' }, h('input', { type: 'checkbox', checked: w.enabled !== false, $bind: [w, 'enabled'], + onchange: (e) => { w.enabled = e.target.checked; row.classList.toggle('off', !w.enabled); changed(); } })), + field('Name', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })), + field('From', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })), + field('To', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })), + field('Earliest', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })), + field('Latest', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })), + h('div', { class: 'rm' }, removeBtn(draft.watches, w, 'watch'))), + h('div', { class: 'wline' }, + field('Cabins', { $bind: [w, 'cabins'] }, h('div', { class: 'cabs' }, cabinBoxes)), + field('Seats', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })), + field('Stops', {}, h('select', { ...bind(w, 'max_stops', optInt) }, + [['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) => + 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', { type: 'checkbox', checked: w.require_both_legs !== false, ...bind(w, 'require_both_legs') })), + field('Nights', { title: 'Optional stay length, between the two departure dates' }, h('div', { class: 'nights' }, + 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) { @@ -875,10 +893,7 @@ h('h3', {}, 'Watches'), 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.'), - h('div', { class: 'scroll' }, h('table', {}, - 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: 'wlist' }, draft.watches.map(watchRow)), h('div', { class: 'btns' }, h('button', { onclick: () => { const today = todayISO(); draft.watches.push({ name: `watch ${draft.watches.length + 1}`, enabled: true, origins: ['ARN'], destinations: [],