# Design — archive-navigation ## Контекст - Роут: `GET /published` (`web/app.py:735`), шаблон `web/templates/published.html` (42 строки). - Дата: `posts.published_at TEXT` — ISO 8601 с таймзоной (`2026-09-08T08:02:45+00:00`). - Сейчас: `LIMIT 100` без пагинации, без фильтров. - Фронтенд: локальный Bootstrap (`web/static/bootstrap.min.css`), без JS-фреймворков, htmx запрещён (AGENT.MD). Паттерн фильтра по направлениям — query-параметры (`_cand_back`). ## 1. Хелпер дерева навигации В `web/app.py`, рядом с `_db()`: ```python MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек") def _archive_tree(conn): """Дерево навигации архива: [{year, months:[{m, label, n}], n}] + {no_date:n}. Строится только из существующих периодов; пустые месяцы отсутствуют.""" rows = conn.execute( """SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m, COUNT(*) n FROM posts WHERE status='published' AND published_at IS NOT NULL AND published_at != '' GROUP BY y, m ORDER BY y DESC, m DESC""" ).fetchall() years, cur = [], None for r in rows: if cur is None or cur["year"] != r["y"]: cur = {"year": r["y"], "months": [], "n": 0} years.append(cur) cur["months"].append({"m": r["m"], "label": f"{MONTH_NAMES_RU[int(r['m'])]} {r['y']}", "n": r["n"]}) cur["n"] += r["n"] no_date = conn.execute( """SELECT COUNT(*) FROM posts WHERE status='published' AND (published_at IS NULL OR published_at='')""" ).fetchone()[0] return {"years": years, "no_date": no_date} ``` - Сортировка: годы и месяцы — по убыванию (свежие сверху, Q9). - Пустой период исключается самой группировкой `GROUP BY` — ровно как Hugo-архивы (`site.RegularPages | groupBy "Date.Year"`), где календарь не итерируется (Q8). ## 2. Роут `GET /published` ```python ARCHIVE_PAGE_SIZE = 10 @app.get("/published", response_class=HTMLResponse) def published(request: Request, year: str = "", month: str = "", page: int = 1): _require_auth(request) page = max(1, page) conn = _db() tree = _archive_tree(conn) where, params = ["p.status='published'"], [] if year: where.append("strftime('%Y', p.published_at) = ?"); params.append(year) if month: where.append("strftime('%m', p.published_at) = ?"); params.append(month) # год без месяца = все посты за год; "Без даты" не попадает в year/month if not year and not month: where.append("(p.published_at IS NOT NULL AND p.published_at != '')") total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {' AND '.join(where)}", params).fetchone()[0] pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE) page = min(page, pages) rows = conn.execute( f"""SELECT p.*, s.name source_name, pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs FROM posts p LEFT JOIN sources s ON s.id=p.source_id LEFT JOIN published pub ON pub.post_id=p.id WHERE {' AND '.join(where)} ORDER BY p.published_at DESC LIMIT ? OFFSET ?""", params + [ARCHIVE_PAGE_SIZE, (page - 1) * ARCHIVE_PAGE_SIZE] ).fetchall() conn.close() html = tpl.get_template("published.html").render( posts=rows, tree=tree, year=year, month=month, page=page, pages=pages, total=total, ) return HTMLResponse(html) ``` - Блок «Без даты» показывается как ссылка `/?year=&month=`? Нет — отдельный фильтр: кнопка «Без даты» (query `nodate=1`), тоже с деревом и пагинацией. (Добавлено в spec ниже — сценарий «Без даты»). ## 3. Шаблон `published.html` - Обёртка: `row` → `col-lg-9` (список) + `col-lg-3` (дерево, `position-sticky top-2`). - Дерево: - ссылка «📄 Все посты» → `/published` (активна, если нет year/month/nodate). - по годам: `
2026 ▾
` + `