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 <noreply@anthropic.com>
This commit is contained in:
Måns SivertssonandClaude Opus 5.5 committed 2026-10-13 08:51:11 +02:00
1 parent 7f5d1bb05f
commit ee74ffb110
1 file changed
+45 -30
+45 -30
View File
@@ -646,24 +646,38 @@
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; } .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 */ /* unsaved edits: yellow = changed, green = new row, red = row will be removed on save */
.changed { background: color-mix(in srgb, var(--warn) 13%, transparent); } .changed { background: color-mix(in srgb, var(--warn) 13%, transparent); }
td.changed, label.changed { border-radius: 4px; } label.changed { padding: 2px 4px; } 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)); } 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 td, .wrow.added, .added .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 { .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)); } 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 td, .wrow.removed, .removed .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)); .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; } border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); text-decoration: line-through; }
tr.removed label, tr.removed input { opacity: .75; } .removed label, .removed input { opacity: .75; }
tr.removed button.icon { color: var(--bad); } .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 { 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 { border:0; border-radius:0; padding:2px 10px; font-size:12px; font-weight:400; }
.seg button.on { background: var(--acc); color:#fff; } .seg button.on { background: var(--acc); color:#fff; }
.toggle { cursor: pointer; user-select: none; } .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); } .chev { display: inline-block; width: 1.1em; color: var(--muted); }
tr.sum td { font-weight: 600; } tr.sum td.muted { font-weight: 400; } 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; }
@@ -741,7 +755,8 @@
h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` + 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' : ''))), ` · ${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')),
summary, summary,
open ? rows.map((r) => h('tr', { class: r.status }, h('td', { class: 'desc' }, r.desc), seatsCell(r), statusCell(r))) : [])) 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 = ''; if (dirty) ed.errors.textContent = '';
for (const [el, [obj, key]] of bound) { for (const [el, [obj, key]] of bound) {
if (!el.isConnected) { bound.delete(el); continue; } 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)); target.classList.toggle('changed', differs(obj, key));
} }
} }
@@ -834,27 +849,30 @@
w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c)); w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c));
changed(); changed();
} }), ` ${CABINS[code]}`)); } }), ` ${CABINS[code]}`));
const tr = h('tr', { class: rowClass(w, w.enabled === false ? 'off' : '') }, 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, $bind: [w, 'enabled'], const row = h('div', { class: ['wrow', rowClass(w, w.enabled === false ? 'off' : '')].filter(Boolean).join(' ') },
onchange: (e) => { w.enabled = e.target.checked; tr.classList.toggle('off', !w.enabled); changed(); } })), 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', $bind: [w, 'cabins'] }, 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'))),
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]) => [['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) =>
h('option', { value: v, selected: String(w.max_stops ?? '') === 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' }, 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') })), h('input', { type: 'checkbox', checked: w.require_both_legs !== false, ...bind(w, 'require_both_legs') })),
h('td', { style: 'white-space:nowrap' }, 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: '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('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 row;
return tr;
} }
function notifyRow(n) { function notifyRow(n) {
@@ -875,10 +893,7 @@
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: [],