date filters: independent Дата начала (start_date>=) / Дата окончания (end_date<=), header fields under Город/Бренд; qualify pf_board.start_date/end_date to avoid alias shadowing

This commit is contained in:
aaverbitskiy 2026-07-21 10:07:39 +00:00
parent fe06cdab72
commit 66e501ef59
6 changed files with 84 additions and 35 deletions

View File

@ -17,26 +17,38 @@
</div>
<!-- Group 2: fields -->
<div class="hgroup">
<div class="field">
<label>Город</label>
<div class="dropdown" id="city-dropdown">
<button type="button" class="dropdown-btn" id="city-btn">
<span id="city-btn-text">Все города</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="city-panel"></div>
<div class="hgroup hgroup-fields">
<div class="fcol">
<div class="field">
<label>Город</label>
<div class="dropdown" id="city-dropdown">
<button type="button" class="dropdown-btn" id="city-btn">
<span id="city-btn-text">Все города</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="city-panel"></div>
</div>
</div>
<div class="field">
<label for="date-start">Дата начала (от)</label>
<input type="date" id="date-start" />
</div>
</div>
<div class="field">
<label>Бренд</label>
<div class="dropdown" id="brand-dropdown">
<button type="button" class="dropdown-btn" id="brand-btn">
<span id="brand-btn-text">Все бренды</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="brand-panel"></div>
<div class="fcol">
<div class="field">
<label>Бренд</label>
<div class="dropdown" id="brand-dropdown">
<button type="button" class="dropdown-btn" id="brand-btn">
<span id="brand-btn-text">Все бренды</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="brand-panel"></div>
</div>
</div>
<div class="field">
<label for="date-end">Дата окончания (до)</label>
<input type="date" id="date-end" />
</div>
</div>
@ -57,7 +69,7 @@
</div>
</div>
<!-- Group 3: checkboxes -->
<!-- Group 3: checkboxes (2-column compact grid) -->
<div class="hgroup hgroup-checks">
<div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>

View File

@ -8,6 +8,8 @@ function buildQuery(f: Filters): string {
dimension: f.dimensions.join(','),
brand: f.brands.join(','),
search: f.search,
date_start: f.dateStart,
date_end: f.dateEnd,
});
return params.toString();
}

View File

@ -9,6 +9,8 @@ import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance'
const el = {
search: document.getElementById('search') as HTMLInputElement,
dateStart: document.getElementById('date-start') as HTMLInputElement,
dateEnd: document.getElementById('date-end') as HTMLInputElement,
status: document.getElementById('status') as HTMLElement,
timelineEl: document.getElementById('timeline') as HTMLElement,
empty: document.getElementById('empty') as HTMLElement,
@ -58,6 +60,8 @@ function currentFilters(): Filters {
dimensions: dimensionDropdown.getSelected(),
brands: brandDropdown.getSelected(),
search: el.search.value.trim(),
dateStart: el.dateStart.value,
dateEnd: el.dateEnd.value,
};
}
@ -114,6 +118,8 @@ cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;

View File

@ -45,6 +45,17 @@ header {
border-radius: 8px;
}
.hgroup-brand { background: #eaeaea; }
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
.hgroup-fields { align-items: flex-start; }
.fcol { display: flex; flex-direction: column; gap: 8px; }
input[type=date] {
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
min-width: 180px;
box-sizing: border-box;
}
/* Compact 2-column layout for the checkbox group. */
.hgroup-checks {
display: grid;

View File

@ -34,6 +34,8 @@ export interface Filters {
dimensions: string[];
brands: string[];
search: string;
dateStart: string; // YYYY-MM-DD or '' — start_date >= dateStart
dateEnd: string; // YYYY-MM-DD or '' — end_date <= dateEnd
}
/** task_status value that marks an archived booking (painted slate #708090). */

50
main.py
View File

@ -20,9 +20,13 @@ SEARCH_COND = (
" OR positionCaseInsensitive(address, {search:String}) > 0"
" OR positionCaseInsensitive(board_id, {search:String}) > 0)"
)
# Independent date bounds: start_date >= X ("Дата начала"), end_date <= Y
# ("Дата окончания"). Each included only when its value is provided.
START_COND = "pf_board.start_date >= {date_start:Date}"
END_COND = "pf_board.end_date <= {date_end:Date}"
def build_where(city=False, dimension=False, brand=False, search=False):
def build_where(city=False, dimension=False, brand=False, search=False, date_start=False, date_end=False):
# Faceted WHERE: include only the requested facet conditions. For the
# dependent-filter option lists we exclude a facet's own condition so its
# available values reflect the OTHER filters (all-but-self).
@ -35,12 +39,13 @@ def build_where(city=False, dimension=False, brand=False, search=False):
parts.append(BRAND_COND)
if search:
parts.append(SEARCH_COND)
if date_start:
parts.append(START_COND)
if date_end:
parts.append(END_COND)
return "\n AND ".join(parts)
WHERE_ALL = build_where(city=True, dimension=True, brand=True, search=True)
async def ch_query(sql: str, params: dict):
form = {"query": sql, "default_format": "JSONEachRow"}
for k, v in params.items():
@ -58,26 +63,35 @@ async def ch_query(sql: str, params: dict):
return [json.loads(line) for line in text.splitlines()]
def parse_filters(city: str, dimension: str, brand: str, search: str):
def parse_filters(city: str, dimension: str, brand: str, search: str, date_start: str, date_end: str):
cities = [c for c in city.split(",") if c] if city else []
dimensions = [d for d in dimension.split(",") if d] if dimension else []
brands = [b for b in brand.split(",") if b] if brand else []
return {"cities": cities, "dimensions": dimensions, "brands": brands, "search": search or ""}
return {
"cities": cities,
"dimensions": dimensions,
"brands": brands,
"search": search or "",
"date_start": date_start or "",
"date_end": date_end or "",
}
@app.get("/api/meta")
async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = ""):
params = parse_filters(city, dimension, brand, search)
async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, search, date_start, date_end)
hds = bool(params["date_start"])
hde = bool(params["date_end"])
cities = await ch_query(
f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, search=True)} AND char_length(city) > 0 ORDER BY city",
f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city",
params,
)
brands = await ch_query(
f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, search=True)} AND char_length(brand) > 0 ORDER BY brand",
f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand",
params,
)
dimensions = await ch_query(
f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, search=True)} AND char_length(dimension) > 0 ORDER BY dimension",
f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension",
params,
)
return {
@ -88,8 +102,9 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", search: str
@app.get("/api/boards")
async def boards(city: str = "", dimension: str = "", brand: str = "", search: str = ""):
params = parse_filters(city, dimension, brand, search)
async def boards(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
@ -97,7 +112,7 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", search: s
argMax(city, last_activity) AS board_city,
argMax(dimension, last_activity) AS board_dimension
FROM default.pf_board
WHERE {WHERE_ALL}
WHERE {where}
GROUP BY board_id
ORDER BY board_address
"""
@ -106,8 +121,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", search: s
@app.get("/api/bookings")
async def bookings(city: str = "", dimension: str = "", brand: str = "", search: str = ""):
params = parse_filters(city, dimension, brand, search)
async def bookings(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
@ -118,7 +134,7 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", search:
brand,
company_name
FROM default.pf_board
WHERE {WHERE_ALL}
WHERE {where}
ORDER BY board_id, start_date
"""
rows = await ch_query(sql, params)