refactor(ui): two-tier bottom dock (collapsible filters over trip cards), full-width map

Moved filters out of the right dock into a bottom dock with two tiers: filter
tier (top) + trip-card tier (beneath) — a selection→results hierarchy. Map is now
full-width. Live mode: filter form expanded, no cards. Trips mode: filters
collapse to a one-line summary (with Edit to re-expand) and cards show beneath,
keeping the map tall. Plate combobox dropdown opens upward. map.resize() on every
tier change. Verified: full-width map in all states, live=form, trips=summary+
cards (150), Edit re-expands with cards still shown, back=form, no overflow, no
errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kianiadee 2026-06-07 00:35:34 +03:00
parent 169fc21f36
commit 70928c0b2d
2 changed files with 126 additions and 108 deletions

View file

@ -41,13 +41,16 @@ trips** into one view for the Fireside Communications / Tracksolid fleet.
(vehicle / cost centre / city) plus the **first trip** and **last trip** (vehicle / cost centre / city) plus the **first trip** and **last trip**
each with its **reverse-geocoded location and timestamp** — alongside the KPI each with its **reverse-geocoded location and timestamp** — alongside the KPI
totals (trips, km, driving/idle hours, vehicles, drivers, date range). totals (trips, km, driving/idle hours, vehicles, drivers, date range).
- **Fixed chrome (no floating panels).** Filters are a **slim, content-height dock - **Full-width map + two-tier bottom dock (no floating/side panels).** All controls
on the right**; in trips view the trips are **cards in a fixed bottom bar** live in a bottom dock with two tiers: a **filter tier** on top and a **trip-card
(horizontally scrollable) that mirrors the top bar. Click a card to fit + tier** beneath (selection → results hierarchy). In **live** mode the filter row
animate that route. is expanded and there are no cards; in **trips** mode the filters **collapse to a
one-line summary** (`Filters: KCA 542Q · roll out · nairobi · Last 1 month`, with
**Edit** to expand) and the trip cards show beneath — keeping the map tall.
- **Plate picker is a searchable combobox** — type to filter, click to add a - **Plate picker is a searchable combobox** — type to filter, click to add a
removable chip (multi-select), instead of a tall scrolling list. Cost centre / removable chip (multi-select), instead of a tall scrolling list. Cost centre /
city / time stay single-line; date pickers appear only for a custom range. city / time stay single-line; date pickers appear only for a custom range.
Trip cards scroll horizontally; click a card to fit + animate that route.
Live: <https://fleetnow.rahamafresh.com> Live: <https://fleetnow.rahamafresh.com>

View file

@ -46,7 +46,7 @@
} }
.app { .app {
display: grid; height: 100vh; width: 100vw; overflow: hidden; display: grid; height: 100vh; width: 100vw; overflow: hidden;
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto minmax(0, 1fr) auto; /* header · context · map · bottom dock */
grid-template-columns: minmax(0, 1fr); /* clamp so the trip-card scroller can't widen the page */ grid-template-columns: minmax(0, 1fr); /* clamp so the trip-card scroller can't widen the page */
} }
@ -121,55 +121,52 @@
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; } .clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
.clock b { font-weight: 600; } .clock b { font-weight: 600; }
/* ── Body: map + fixed right filter dock + fixed bottom trips bar ───── */ /* ── Layout: header · context · map · two-tier bottom dock ───────────── */
header { grid-row: 1; } header { grid-row: 1; }
.tripbar { grid-row: 2; } .tripbar { grid-row: 2; }
.body { #map { grid-row: 3; position: relative; min-height: 0; }
grid-row: 3; min-height: 0;
display: grid;
/* minmax(0,…) so the horizontal trip-card scroller can't blow out the track */
grid-template-columns: minmax(0, 1fr) 224px; /* map | slim filter dock */
grid-template-rows: minmax(0, 1fr) auto; /* map | fixed trips bar */
}
#map { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
.placeholder { .placeholder {
position: absolute; inset: 0; display: grid; place-items: center; position: absolute; inset: 0; display: grid; place-items: center;
color: var(--muted); font-size: 13px; text-align: center; padding: 32px; color: var(--muted); font-size: 13px; text-align: center; padding: 32px;
pointer-events: none; z-index: 1; pointer-events: none; z-index: 1;
} }
.dockbar {
grid-row: 4; min-width: 0; display: flex; flex-direction: column;
background: var(--panel); border-top: 1px solid var(--border);
}
/* Slim, content-height filter card — docked in the right column (top-aligned, /* Filter tier — expanded form (live / editing) ⇄ collapsed summary (trips) */
so no dead space below the button), part of the layout (won't drift). */ .filter-summary { display: none; align-items: center; gap: 12px; padding: 9px 16px; }
.filters { .filter-tier.collapsed .filter-summary { display: flex; }
grid-column: 2; grid-row: 1 / span 2; align-self: start; .filter-tier.collapsed .filter-form { display: none; }
margin: 12px 12px 0 0; max-height: calc(100% - 24px); overflow-y: auto; .fs-label { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); font-weight: 600; }
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; .fs-text { color: var(--text); font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
padding: 13px 13px 14px; .fs-edit {
margin-left: auto; background: var(--panel-2); color: var(--accent);
border: 1px solid var(--border); border-radius: 6px; padding: 5px 12px;
font: 600 12px system-ui; cursor: pointer; white-space: nowrap;
} }
.filters h3 { .fs-edit:hover { border-color: var(--accent); }
font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
color: var(--muted); margin: 0 0 12px; font-weight: 600; .filter-form { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 12px; padding: 10px 16px 12px; }
} .ff-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.field { display: flex; flex-direction: column; margin-bottom: 11px; } .ff-field > label {
.field label { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
color: var(--muted); margin-bottom: 4px;
} }
.ff-plate { width: 230px; }
select, input[type=date] { select, input[type=date] {
padding: 7px 9px; background: var(--bg); color: var(--text); padding: 7px 9px; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%; border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; width: 100%;
} }
select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } select:focus, input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.hint { font-size: 9.5px; color: var(--muted); margin-top: 3px; line-height: 1.3; } .ff-field.custom { display: none; }
.custom { display: none; } .ff-field.custom.show { display: flex; }
.custom.show { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .ff-go { width: auto; margin: 0; align-self: flex-end; padding: 8px 18px; }
.custom .field { margin-bottom: 0; }
.custom input[type=date] { padding: 6px 6px; font-size: 11.5px; }
/* Searchable plate combobox + chips (replaces the tall multi-select) */ /* Searchable plate combobox + chips — dropdown opens UPWARD (bar is at bottom) */
.plate-box { position: relative; } .plate-box { position: relative; }
.plate-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 5px; } .plate-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.plate-chips:empty { display: none; } .plate-chips:not(:empty) { margin-bottom: 5px; }
.plate-chip { .plate-chip {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
background: var(--accent); color: #1a1009; font: 600 11px system-ui; background: var(--accent); color: #1a1009; font: 600 11px system-ui;
@ -180,9 +177,9 @@
.plate-search { width: 100%; padding: 7px 9px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; } .plate-search { width: 100%; padding: 7px 9px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: 13px system-ui; }
.plate-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; } .plate-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.plate-results { .plate-results {
display: none; position: absolute; z-index: 20; left: 0; right: 0; top: 100%; margin-top: 3px; display: none; position: absolute; z-index: 20; left: 0; right: 0; bottom: 100%; margin-bottom: 4px;
max-height: 200px; overflow-y: auto; background: var(--panel-2); max-height: 240px; overflow-y: auto; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 22px rgba(0,0,0,.5); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 -8px 22px rgba(0,0,0,.5);
} }
.plate-results.show { display: block; } .plate-results.show { display: block; }
.plate-opt { padding: 7px 10px; font-size: 12.5px; cursor: pointer; color: var(--text); } .plate-opt { padding: 7px 10px; font-size: 12.5px; cursor: pointer; color: var(--text); }
@ -191,30 +188,17 @@
.plate-opt .pd { color: var(--muted); font-size: 11px; } .plate-opt .pd { color: var(--muted); font-size: 11px; }
.plate-none { padding: 8px 10px; color: var(--muted); font-size: 11.5px; } .plate-none { padding: 8px 10px; color: var(--muted); font-size: 11.5px; }
.btn { .btn {
width: 100%; padding: 10px; margin-top: 6px; background: var(--accent); padding: 10px; background: var(--accent);
color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer; color: #1a1009; border: 0; border-radius: 6px; font: 600 13px system-ui; cursor: pointer;
} }
.btn:hover { background: var(--accent-hover); } .btn:hover { background: var(--accent-hover); }
.btn:disabled { background: #4b5563; color: #cbd5e1; cursor: wait; } .btn:disabled { background: #4b5563; color: #cbd5e1; cursor: wait; }
/* Fixed bottom trips bar — horizontal scrolling cards (mirrors the top bar) */ /* Card tier (beneath the filter tier; trips mode only) */
.tripsbar { .cards-tier { display: none; flex-direction: column; min-width: 0; border-top: 1px solid var(--border); }
grid-column: 1; grid-row: 2; display: none; .cards-tier.show { display: flex; }
background: var(--panel); border-top: 1px solid var(--border); .cards-tier-head { padding: 7px 16px 0; }
flex-direction: column; min-width: 0; .tripsbar-title { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); font-weight: 600; }
}
.tripsbar.show { display: flex; }
.tripsbar-head {
display: flex; align-items: center; gap: 14px; padding: 7px 16px 0;
}
.tripsbar-head .back {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
color: var(--accent); font: 600 12px system-ui; background: none; border: 0; padding: 0;
}
.tripsbar-head .back:hover { color: var(--accent-hover); }
.tripsbar-title {
font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); font-weight: 600;
}
.tripsbar-title span { color: var(--text); } .tripsbar-title span { color: var(--text); }
.trip-cards { .trip-cards {
display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden; display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
@ -384,33 +368,40 @@
</div> </div>
</div> </div>
<div class="body">
<div id="map"> <div id="map">
<div class="placeholder" id="placeholder">Loading live fleet…</div> <div class="placeholder" id="placeholder">Loading live fleet…</div>
</div> </div>
<!-- Filters — fixed right dock (always) --> <!-- Bottom dock: two tiers — filter tier (top) + trip-card tier (beneath) -->
<aside class="filters" id="filters"> <div class="dockbar" id="dockbar">
<h3>Filters</h3>
<div class="field"> <!-- Filter tier: collapsed summary (trips) ⇄ expanded form -->
<div class="filter-tier" id="filter-tier">
<div class="filter-summary" id="filter-summary">
<span class="fs-label">Filters</span>
<span class="fs-text" id="fs-text">Whole fleet · Today</span>
<button class="fs-edit" id="filter-edit" type="button">✎ Edit</button>
</div>
<div class="filter-form" id="filter-form">
<div class="ff-field ff-plate">
<label for="plate-search">Number plate</label> <label for="plate-search">Number plate</label>
<div class="plate-box"> <div class="plate-box">
<div class="plate-chips" id="plate-chips"></div> <div class="plate-chips" id="plate-chips"></div>
<input type="text" id="plate-search" class="plate-search" placeholder="Search plate…" autocomplete="off"> <input type="text" id="plate-search" class="plate-search" placeholder="Search plate…" autocomplete="off">
<div class="plate-results" id="plate-results"></div> <div class="plate-results" id="plate-results"></div>
</div> </div>
<!-- Source of truth for all filter logic; UI above drives it. -->
<select id="f-vehicle" multiple hidden></select> <select id="f-vehicle" multiple hidden></select>
</div> </div>
<div class="field"> <div class="ff-field">
<label for="f-cc">Cost centre</label> <label for="f-cc">Cost centre</label>
<select id="f-cc"><option value="">All cost centres</option></select> <select id="f-cc"><option value="">All cost centres</option></select>
</div> </div>
<div class="field"> <div class="ff-field">
<label for="f-city">Assigned city</label> <label for="f-city">Assigned city</label>
<select id="f-city"><option value="">All cities</option></select> <select id="f-city"><option value="">All cities</option></select>
</div> </div>
<div class="field"> <div class="ff-field">
<label for="f-period">Time</label> <label for="f-period">Time</label>
<select id="f-period"> <select id="f-period">
<option value="today" selected>Today</option> <option value="today" selected>Today</option>
@ -419,17 +410,19 @@
<option value="custom">Custom range</option> <option value="custom">Custom range</option>
</select> </select>
</div> </div>
<div class="custom" id="custom"> <div class="ff-field custom" id="custom">
<div class="field"><label for="f-start">Start date</label><input type="date" id="f-start"></div> <label for="f-start">Start</label><input type="date" id="f-start">
<div class="field"><label for="f-end">End date</label><input type="date" id="f-end"></div> </div>
<div class="ff-field custom" id="custom-end">
<label for="f-end">End</label><input type="date" id="f-end">
</div>
<button class="btn ff-go" id="show-trips" type="button">Show trips</button>
</div>
</div> </div>
<button class="btn" id="show-trips" type="button">Show trips</button>
</aside>
<!-- Trips — fixed bottom bar of horizontal cards (trips mode only) --> <!-- Card tier: trips (trips mode only) -->
<div class="tripsbar" id="tripsbar"> <div class="cards-tier" id="cards-tier">
<div class="tripsbar-head"> <div class="cards-tier-head">
<button class="back" id="trips-back" type="button">◀ Live</button>
<span class="tripsbar-title">TRIPS <span id="trip-count"></span></span> <span class="tripsbar-title">TRIPS <span id="trip-count"></span></span>
</div> </div>
<div class="trip-cards" id="trip-scroll"> <div class="trip-cards" id="trip-scroll">
@ -1062,8 +1055,29 @@ function updateVehScale() {
} }
document.getElementById('f-period').addEventListener('change', e => { document.getElementById('f-period').addEventListener('change', e => {
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom'); const show = e.target.value === 'custom';
document.getElementById('custom').classList.toggle('show', show);
document.getElementById('custom-end').classList.toggle('show', show);
}); });
// Filter tier: collapse to a one-line summary (trips) vs the full form.
function setFilterExpanded(expanded) {
document.getElementById('filter-tier').classList.toggle('collapsed', !expanded);
if (map) setTimeout(() => map.resize(), 0); // dock height changed
}
function renderFilterSummary() {
const sel = currentFilterSelection();
const parts = [];
if (sel.vehicles.length === 1) parts.push(sel.vehicles[0]);
else if (sel.vehicles.length > 1) parts.push(`${sel.vehicles.length} vehicles`);
if (sel.cost_centre) parts.push(sel.cost_centre);
if (sel.assigned_city) parts.push(sel.assigned_city);
if (!parts.length) parts.push('Whole fleet');
const periodLabel = { today: 'Today', '7d': 'Last 1 week', '30d': 'Last 1 month', custom: `${sel.start || '?'} → ${sel.end || '?'}` }[sel.period] || sel.period;
parts.push(periodLabel);
document.getElementById('fs-text').textContent = parts.join(' · ');
}
document.getElementById('filter-edit').addEventListener('click', () => setFilterExpanded(true));
// Selecting a cost centre or assigned city filters the live map immediately. // Selecting a cost centre or assigned city filters the live map immediately.
document.getElementById('f-cc').addEventListener('change', applyLiveFilters); document.getElementById('f-cc').addEventListener('change', applyLiveFilters);
document.getElementById('f-city').addEventListener('change', applyLiveFilters); document.getElementById('f-city').addEventListener('change', applyLiveFilters);
@ -1138,10 +1152,12 @@ function switchToTripsMode() {
clearTrail(); trailedVehicle = null; clearTrail(); trailedVehicle = null;
liveMarkers.forEach(m => m.getElement().style.display = 'none'); liveMarkers.forEach(m => m.getElement().style.display = 'none');
clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear(); clusterMarkers.forEach(m => m.remove()); clusterMarkers.clear();
document.getElementById('tripsbar').classList.add('show'); document.getElementById('cards-tier').classList.add('show');
document.getElementById('tripbar').classList.add('show'); document.getElementById('tripbar').classList.add('show');
document.getElementById('live-pill').classList.add('show'); document.getElementById('live-pill').classList.add('show');
document.getElementById('stale-chip').style.display = 'none'; document.getElementById('stale-chip').style.display = 'none';
renderFilterSummary();
setFilterExpanded(false); // collapse filters to the one-line summary
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none'; const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
if (map) setTimeout(() => map.resize(), 0); // map column shrank — let it reflow if (map) setTimeout(() => map.resize(), 0); // map column shrank — let it reflow
} }
@ -1337,18 +1353,17 @@ function showTripPlaceholder(msg) {
function backToLive() { function backToLive() {
mode = 'live'; mode = 'live';
clearAnim(); removeTripLayers(); clearAnim(); removeTripLayers();
document.getElementById('tripsbar').classList.remove('show'); document.getElementById('cards-tier').classList.remove('show');
document.getElementById('tripbar').classList.remove('show'); document.getElementById('tripbar').classList.remove('show');
document.getElementById('live-pill').classList.remove('show'); document.getElementById('live-pill').classList.remove('show');
document.getElementById('stale-chip').style.display = ''; document.getElementById('stale-chip').style.display = '';
setFilterExpanded(true); // back to the full filter form for live
liveMarkers.forEach(m => m.getElement().style.display = ''); liveMarkers.forEach(m => m.getElement().style.display = '');
if (map) setTimeout(() => map.resize(), 0); // bottom bar gone — map grows back
if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins if (liveFeatures.length) applyLiveFilters(); // rebuild cluster bubbles/pins
const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none'; const ph = document.getElementById('placeholder'); if (ph) ph.style.display = 'none';
startPolling(); startPolling();
} }
document.getElementById('live-pill').addEventListener('click', backToLive); document.getElementById('live-pill').addEventListener('click', backToLive);
document.getElementById('trips-back').addEventListener('click', backToLive);
// ============================================================================ // ============================================================================
// Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache // Reverse-geocoding (Nominatim) — queued, 1 req/sec, in-memory cache