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:
1 parent
7f5d1bb05f
commit
ee74ffb110
1 file changed
+50
-35
+50
-35
@@ -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: [],
|
||||
|
||||
Reference in new issue
Block a user