From 5cabad9c335d3ed2459a171642e469ef3406a70f Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Fri, 14 Aug 2026 14:48:25 +0000 Subject: [PATCH] feat: all-surfaces timeline + UI polish (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/index.html | 13 ++++++--- frontend/package.json | 2 +- frontend/src/api.ts | 5 +++- frontend/src/main.ts | 17 ++++++++++-- frontend/src/map.ts | 4 ++- frontend/src/styles.css | 5 ++++ frontend/src/timeline.ts | 18 ++++++++++--- main.py | 57 ++++++++++++++++++++++++++++++---------- 8 files changed, 94 insertions(+), 27 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 6c87cd8..212de32 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -114,14 +114,19 @@
-
- +
+
+ +
+
+ +
- +
@@ -133,7 +138,7 @@
- +
diff --git a/frontend/package.json b/frontend/package.json index 1316acf..cdb17af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d193a4b..71a3ca6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -27,7 +27,10 @@ async function getJson(url: string): Promise { export const api = { meta: (f: Filters): Promise => getJson('/api/meta?' + buildQuery(f)), - boards: (f: Filters): Promise => getJson('/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 => + getJson('/api/boards?' + buildQuery(f) + '&all_surfaces=' + (allSurfaces ? '1' : '0')), bookings: (f: Filters): Promise => getJson('/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. diff --git a/frontend/src/main.ts b/frontend/src/main.ts index bb66ed2..e018b04 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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 { 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 { lastBoards = boards; lastBookings = bookings; view.render(boards, bookings, fit, flags()); - el.status.innerHTML = `Поверхности: ${boards.length}
Брони: ${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); diff --git a/frontend/src/map.ts b/frontend/src/map.ts index cf24670..3e37ecf 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -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) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d802aa1..7c87bfb 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts index 2eb022d..5dd397d 100644 --- a/frontend/src/timeline.ts +++ b/frontend/src/timeline.ts @@ -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 ---- diff --git a/main.py b/main.py index 71d98ef..4e51328 100644 --- a/main.py +++ b/main.py @@ -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