feat: all-surfaces timeline + UI polish (v0.2.0)

Timeline can now list the entire inventory (occupied + free + never
booked), not just booked surfaces. New manager-only "Все поверхности"
checkbox (on by default; forced on and hidden for anonymous visitors so
they can spot what is currently free with no future bookings).

/api/boards gains all_surfaces: sources rows from board_info and unions
pf_board so a just-booked surface not yet in the inventory snapshot still
gets a row. Surface facets (city/dimension/search) narrow rows; booking
facets (brand/manager/status) narrow to booked surfaces; dates are a
window that narrows bars, not the surface set.

UI polish:
- timeline tooltip flips up/left near viewport edges
- anonymous "Войти" button filled accent, inverts to white-on-accent hover
- appearance button uses a gear icon instead of "+"
- header counter shows only "Поверхности: N"
- map "всего" counter includes surfaces without coordinates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-14 14:48:25 +00:00
parent 341bc42778
commit 5cabad9c33
8 changed files with 94 additions and 27 deletions

View File

@ -114,14 +114,19 @@
</div>
</div>
<div class="checks manager-only">
<div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
<div class="checks-row">
<div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
</div>
<div class="field checkbox-field">
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label>
</div>
</div>
<div class="field checkbox-field">
<label for="show-collisions"><input type="checkbox" id="show-collisions" checked /> Коллизии размещения</label>
</div>
<div class="field checkbox-field">
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label>
<label for="show-all-surfaces"><input type="checkbox" id="show-all-surfaces" checked /> Все поверхности</label>
</div>
</div>
</div>
@ -133,7 +138,7 @@
<!-- Appearance ("+") button, pinned to the header's bottom-right corner -->
<div class="decor-wrap manager-only">
<button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление">+</button>
<button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<div class="decor-menu" id="decor-menu"></div>
</div>
</header>

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -27,7 +27,10 @@ async function getJson<T>(url: string): Promise<T> {
export const api = {
meta: (f: Filters): Promise<Meta> => getJson<Meta>('/api/meta?' + buildQuery(f)),
boards: (f: Filters): Promise<Board[]> => getJson<Board[]>('/api/boards?' + buildQuery(f)),
// allSurfaces=true adds every inventory surface (occupied + free + never
// booked) as a timeline row; the backend forces it on for anonymous visitors.
boards: (f: Filters, allSurfaces: boolean): Promise<Board[]> =>
getJson<Board[]>('/api/boards?' + buildQuery(f) + '&all_surfaces=' + (allSurfaces ? '1' : '0')),
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)),
// Map surfaces. The backend ignores the date params here (occupancy is "today"),
// but we reuse buildQuery — extra query params are harmless.

View File

@ -32,6 +32,7 @@ const el = {
showBrand: document.getElementById('show-brand') as HTMLInputElement,
showCompany: document.getElementById('show-company') as HTMLInputElement,
showCollisions: document.getElementById('show-collisions') as HTMLInputElement,
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
@ -80,6 +81,9 @@ function applyRoleUi(): void {
// Login / logout button.
el.authBtn.style.display = '';
el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
// Anonymous "Войти" is highlighted (filled accent) so it stands out; once
// logged in the "Выйти" button reverts to the subtle outline style.
el.authBtn.classList.toggle('auth-btn--login', !isAuthenticated());
el.authBtn.onclick = () => (isAuthenticated() ? logout() : login());
}
@ -143,6 +147,13 @@ function flags(): RenderFlags {
};
}
// Whether the timeline should list every inventory surface (not just booked
// ones). The checkbox is manager-only; anonymous visitors always get all
// surfaces so they can spot what's currently free with no future bookings.
function allSurfacesParam(): boolean {
return !isManager() || el.showAllSurfaces.checked;
}
function currentFilters(): Filters {
return {
cities: cityDropdown.getSelected(),
@ -292,7 +303,7 @@ async function loadData(fit: boolean): Promise<void> {
loadStart();
try {
const f = currentFilters();
const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f), api.bookings(f)]);
const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f, allSurfacesParam()), api.bookings(f)]);
// Dependent filters: refresh each dropdown's options (selections preserved).
cityDropdown.updateValues(meta.cities);
brandDropdown.updateValues(meta.brands);
@ -302,7 +313,7 @@ async function loadData(fit: boolean): Promise<void> {
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.innerHTML = `Поверхности: ${boards.length}<br>Брони: ${bookings.length}`;
el.status.textContent = `Поверхности: ${boards.length}`;
rebuildDecorMenu(); // status list may have changed
renderChips();
filtersToUrl();
@ -374,6 +385,8 @@ function scheduleReload(): void {
for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
cb.addEventListener('change', () => view.render(lastBoards, lastBookings, false, flags()));
}
// "All surfaces" changes which rows exist (server-side), so it must refetch.
el.showAllSurfaces.addEventListener('change', scheduleReload);
cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);

View File

@ -229,7 +229,9 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
// Live legend counts (react to the current filter).
if (legendFree) legendFree.textContent = `свободно: ${free}`;
if (legendBusy) legendBusy.textContent = `занято: ${busy}`;
els.counter.textContent = `всего: ${withCoords.length}`;
// Total counts the whole inventory, including surfaces without coordinates
// (which can't be drawn but are still surfaces — listed under "без координат").
els.counter.textContent = `всего: ${surfaces.length}`;
// Actionable "no coordinates" badge + list.
if (noCoordsBtn) {

View File

@ -163,6 +163,7 @@ input::placeholder { color: var(--text-muted); }
.zoom-row { display: flex; align-items: center; gap: 8px; }
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.checks .checkbox-field { align-self: flex-start; }
.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; }
/* Let the brand block fill the header height so the counters can sit at the
bottom, level with the date fields / mode switch. */
@ -329,6 +330,10 @@ html.role-pending .manager-only { display: none !important; }
transition: border-color var(--transition), background var(--transition);
}
.auth-btn:hover { border-color: var(--border-strong); background: var(--surface); }
/* Anonymous "Войти": filled accent for visibility; hover inverts to white with
an accent outline. */
.auth-btn--login { background: var(--accent); border-color: var(--accent); color: #fff; }
.auth-btn--login:hover { background: var(--bg); border-color: var(--accent); color: var(--accent); }
/* Dark theme temporarily hidden toggle stays in the DOM, just not shown.
Re-enable by removing this rule + restoring the head script in index.html. */
.theme-toggle { display: none; }

View File

@ -224,10 +224,20 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
el.tooltip.style.display = 'none';
});
document.addEventListener('mousemove', (e) => {
if (hoveredItemId !== null) {
el.tooltip.style.left = e.clientX + 12 + 'px';
el.tooltip.style.top = e.clientY + 12 + 'px';
}
if (hoveredItemId === null) return;
const pad = 12;
const tw = el.tooltip.offsetWidth;
const th = el.tooltip.offsetHeight;
let left = e.clientX + pad;
let top = e.clientY + pad;
// Flip above the cursor when the tooltip would spill past the bottom edge.
if (top + th > window.innerHeight - 8) top = e.clientY - pad - th;
// Flip to the left when it would spill past the right edge.
if (left + tw > window.innerWidth - 8) left = e.clientX - pad - tw;
if (top < 8) top = 8;
if (left < 8) left = 8;
el.tooltip.style.left = left + 'px';
el.tooltip.style.top = top + 'px';
});
// ---- row hover highlight ----

57
main.py
View File

@ -216,24 +216,53 @@ async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "
@app.get("/api/boards")
async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = "", all_surfaces: bool = False):
# Board inventory (address/city/dimension) is visible to everyone; anonymous
# just can't filter by the hidden facets.
# just can't filter by the hidden facets. The "all surfaces" default for
# anonymous is driven by the client (allSurfacesParam) — the data here is
# non-sensitive inventory, so no server-side force is needed.
if role != "manager":
brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
argMax(address, last_activity) AS board_address,
argMax(city, last_activity) AS board_city,
argMax(dimension, last_activity) AS board_dimension
FROM default.pf_board
WHERE {where}
GROUP BY board_id
ORDER BY board_address
"""
# A "booking facet" (brand/manager/status) narrows to specific bookings, so
# when one is active the timeline shows only the relevant booked surfaces
# regardless of the toggle. Dates are a *window*, not an identity filter:
# they narrow the bars, not the surface set, so free surfaces stay visible.
booking_facet = bool(params["brands"] or params["managers"] or params["statuses"])
if all_surfaces and not booking_facet:
# Full inventory from board_info. Union pf_board so a just-booked surface
# not yet in the inventory snapshot still gets a row (board_info wins the
# label via prio). Only the surface facets (city/dimension/search) apply.
surface_where = " AND ".join(["char_length(city) > 0", CITY_COND, DIM_COND, SEARCH_COND])
sql = f"""
SELECT
board_id,
argMax(board_address, prio) AS board_address,
argMax(board_city, prio) AS board_city,
argMax(board_dimension, prio) AS board_dimension
FROM (
SELECT board_id, address AS board_address, city AS board_city, dimension AS board_dimension, 1 AS prio
FROM default.board_info FINAL WHERE {surface_where}
UNION ALL
SELECT board_id, address AS board_address, city AS board_city, dimension AS board_dimension, 0 AS prio
FROM default.pf_board FINAL WHERE {surface_where}
)
GROUP BY board_id
ORDER BY board_city, board_address
"""
else:
where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
argMax(address, last_activity) AS board_address,
argMax(city, last_activity) AS board_city,
argMax(dimension, last_activity) AS board_dimension
FROM default.pf_board
WHERE {where}
GROUP BY board_id
ORDER BY board_address
"""
rows = await ch_query(sql, params)
return rows