diff --git a/openspec/changes/archive-navigation/.openspec.yaml b/openspec/changes/archive-navigation/.openspec.yaml
new file mode 100644
index 0000000..b0da164
--- /dev/null
+++ b/openspec/changes/archive-navigation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-20
\ No newline at end of file
diff --git a/openspec/changes/archive-navigation/design.md b/openspec/changes/archive-navigation/design.md
new file mode 100644
index 0000000..bca515c
--- /dev/null
+++ b/openspec/changes/archive-navigation/design.md
@@ -0,0 +1,124 @@
+# 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 ▾
` + `
` месяцев; месяц — ссылка
+ `/published?year=2026&month=09`, счётчик `(4)`. Активный месяц подсвечен
+ (`active-row`-стиль, как в кандидатах).
+ - на мобильных: дерево в `collapse` (кнопка «🗂 Архив» + `data-bs-toggle`).
+- Список: карточки как сейчас, но посты только за выбранный период.
+- Пагинация снизу: Bootstrap `pagination`, `page-item active` для текущей,
+ prev/next, номера 1..pages (с сохранением year/month/nodate).
+- Пустой период: `alert-info` «Нет постов за этот период» (дерево всё равно видно).
+
+## 4. Пагинация
+
+- Bootstrap HTML, без JS: `?year=2026&month=09&page=N`.
+- Текущая страница — `active`, prev/next — disabled на границах.
+- total = число постов в периоде; pages = ceil(total/10).
+
+## 5. Проверка
+
+- `openspec validate archive-navigation` — чисто.
+- `curl -s 'http://127.0.0.1:8400/published?year=2026&month=09'` — видно 4 поста.
+- `curl -s 'http://127.0.0.1:8400/published?month=07'` — 2 поста.
+- В HTML дерева нет месяца без постов (только 07, 08, 09) — «февраль отсутствует».
+- `?page=1`..N — пагинация с номерами; вне диапазона — клампится к последней.
+- Мобильная вёрстка: сужение окна → колонка сворачивается.
\ No newline at end of file
diff --git a/openspec/changes/archive-navigation/proposal.md b/openspec/changes/archive-navigation/proposal.md
new file mode 100644
index 0000000..9484dc8
--- /dev/null
+++ b/openspec/changes/archive-navigation/proposal.md
@@ -0,0 +1,40 @@
+# archive-navigation
+
+Страница «Опубликовано» (`/published`) — настоящий архив с навигацией по годам и месяцам.
+
+## Why
+
+Сейчас `/published` — просто плоский список последних 100 опубликованных постов
+без какой-либо навигации. С ростом архива найти пост по дате невозможно: нет ни
+периодов, ни фильтров, ни разбивки. Это противоречит назначению страницы —
+«архив опубликованного».
+
+Пользователь хочет классический архив: дерево лет/месяцев слева или справа,
+посты — за выбранный период. Чтобы не занимать место вверху страницы
+(навигация вверху слишком громоздкая), выбрана правая колонка (Q1–Q10,
+grill-with-docs 2026-09-20, записано в AGENT.MD).
+
+## What Changes
+
+- **web/app.py**, роут `GET /published`:
+ - принимает query-параметры `year` (YYYY), `month` (MM), `page` (по умолчанию 1).
+ - строит дерево навигации: `SELECT strftime('%Y', published_at) y, strftime('%m', published_at) m, COUNT(*) n FROM posts WHERE status='published' GROUP BY y, m` → группировка по годам, внутри — месяцы с количеством. Группа «Без даты» — посты с пустым `published_at` (характер: могут появиться; сейчас 0).
+ - фильтрует посты по выбранному периоду: `year` + `month` (все посты за год, если месяц не указан), либо «Все посты» (без параметров).
+ - пагинация по 10 постов (LIMIT 10 OFFSET).
+ - рендер в `published.html` с деревом навигации, списком постов, подсветкой активного периода.
+- **web/templates/published.html**:
+ - правая колонка (Bootstrap `col-md-3` + sticky) — дерево годов/месяцев со счётчиками, ссылка «Все посты», группа «Без даты»; пустые периоды не выводятся.
+ - слева — список постов за выбранный период + Bootstrap pagination по 10 (prev/next + номера).
+ - на мобильных — колонка сворачивается (Bootstrap collapse).
+- **web/app.py**: хелпер построения дерева (чистая функция, тестируемая).
+- Обновить `STATUS.md`, `CONTEXT.md` (глоссарий уже добавлен).
+
+## Rollback
+
+- Откат: `git checkout -- web/app.py web/templates/published.html` (change фиксирует
+ состояние до правок), рестарт vesti-web.
+
+## Затронутые сервисы/порты
+
+- vesti-web (:8400), внешний https://vesti.nixg.ru. БД SQLite — только чтение
+ через существующий `_db()` (WAL + busy_timeout).
\ No newline at end of file
diff --git a/openspec/changes/archive-navigation/specs/archive-navigation/spec.md b/openspec/changes/archive-navigation/specs/archive-navigation/spec.md
new file mode 100644
index 0000000..73699de
--- /dev/null
+++ b/openspec/changes/archive-navigation/specs/archive-navigation/spec.md
@@ -0,0 +1,97 @@
+# Spec — archive-navigation
+
+## ADDED Requirements
+
+### Requirement: Архив с навигацией по годам и месяцам
+
+Страница «Опубликовано» (`/published`) показывает опубликованные посты,
+сгруппированные по времени публикации, с навигацией по годам и месяцам
+в правой колонке. Посты показываются за выбранный период (месяц, год или все).
+
+#### Scenario: Открытие архива без параметров
+
+Given модератор открывает `/published` без параметров
+Then показываются опубликованные посты (страница 1, по 10)
+And в правой колонке — дерево навигации: годы, внутри — месяцы со счётчиками
+And дерево содержит только периоды, в которых есть посты
+And сверху дерева есть ссылка «Все посты»
+
+#### Scenario: Выбор месяца
+
+Given в дереве навигации есть месяц «Сен 2026» (4 поста)
+When модератор кликает «Сен 2026»
+Then показываются только посты за сентябрь 2026 (4 поста)
+And месяц подсвечен в дереве как активный
+
+#### Scenario: Выбор года (без месяца)
+
+Given модератор на странице архива
+When модератор кликает на год «2026»
+Then показываются все посты за 2026 год целиком (без разбивки по месяцам)
+And год подсвечен в дереве как активный
+
+#### Scenario: Пустой период не появляется в навигации
+
+Given в архиве нет постов за февраль 2026
+When модератор смотрит дерево навигации
+Then в дереве нет ссылки/узла «Фев 2026»
+(Периоды в дереве порождаются только постами, как в Hugo-архивах)
+
+### Requirement: Пагинация по 10 постов
+
+Список постов на странице архива разбивается постранично, по 10 постов
+на страницу. Навигация — Bootstrap-пагинация с номерами и prev/next,
+сохраняющая выбранный период.
+
+#### Scenario: Переход на следующую страницу
+
+Given в выбранном периоде 25 постов (3 страницы)
+When модератор кликает «2» в пагинации
+Then показываются посты 11–20 за тот же период
+And адрес содержит `page=2` и сохранённые `year`/`month`
+
+#### Scenario: Пагинация за пределами диапазона
+
+Given в периоде 1 страница постов
+When модератор запрашивает `page=5`
+Then показывается последняя (единственная) страница, без ошибки
+
+### Requirement: Группа «Без даты»
+
+Посты со статусом «опубликован», у которых не заполнена дата публикации,
+не теряются: они показываются в отдельной группе «Без даты» в дереве
+навигации и по клику — список этих постов.
+
+#### Scenario: Пост без даты виден в архиве
+
+Given существует опубликованный пост с пустым `published_at`
+When модератор открывает `/published`
+Then в дереве навигации есть ссылка «Без даты»
+And по клику показываются посты без даты (с пагинацией)
+
+### Requirement: Правая колонка навигации
+
+Дерево навигации располагается в правой колонке страницы и остаётся
+видимым при прокрутке (position-sticky). На узких экранах колонка
+сворачивается под кнопку «🗂 Архив» (Bootstrap collapse).
+
+#### Scenario: Правая колонка при прокрутке
+
+Given модератор прокручивает длинный список постов
+Then дерево навигации остаётся видимым справа (sticky)
+
+#### Scenario: Мобильный вид
+
+Given модератор открывает архив на узком экране (< 768px)
+Then дерево навигации свёрнуто под кнопку «🗂 Архив» и раскрывается по клику
+
+### Requirement: Пустой период — заглушка
+
+Если в выбранном периоде нет постов, показывается сообщение
+«Нет постов за этот период», дерево навигации остаётся доступным.
+
+#### Scenario: Запрос пустого периода напрямую
+
+Given модератор открывает `/published?year=2025&month=01` (нет постов)
+Then показывается заглушка «Нет постов за этот период»
+And дерево навигации справа доступно для перехода
\ No newline at end of file
diff --git a/openspec/changes/archive-navigation/tasks.md b/openspec/changes/archive-navigation/tasks.md
new file mode 100644
index 0000000..9f2bedb
--- /dev/null
+++ b/openspec/changes/archive-navigation/tasks.md
@@ -0,0 +1,12 @@
+# Задачи
+
+- [x] web/app.py: хелпер `_archive_tree(conn)` — дерево годов/месяцев GROUP BY + счётчики + «Без даты»
+- [x] web/app.py: роут `GET /published` — параметры year/month/page, фильтр периода, пагинация по 10 (LIMIT/OFFSET, клампинг)
+- [x] web/templates/published.html: правая колонка (col-lg-3 sticky) — дерево навигации, «Все посты», «Без даты»
+- [x] web/templates/published.html: список постов за период + Bootstrap pagination (prev/next + номера)
+- [x] web/templates/published.html: collapse на мобильных (кнопка «🗂 Архив»)
+- [x] web/app.py: `_archive_q` + `tpl.globals["_published_q"]` — построение query-строки с сохранением фильтров
+- [x] Тест: py_compile + рендер шаблона (дерево, пагинация, active-класс) через .venv
+- [x] openspec validate archive-navigation — чисто (только стилевые SHALL/MUST warned, спека по-русски)
+- [x] Рестарт vesti-web, проверка: /published (дерево 2026, 8 постов, без дублей), ?year=2026&month=09 (4), month=08 (2), 2025 (заглушка), пагинация скрыта при 1 стр, битые /published? — 0
+- [ ] Обновить STATUS.md / TODO.md
\ No newline at end of file
diff --git a/web/app.py b/web/app.py
index 56b1e5a..3f7b984 100644
--- a/web/app.py
+++ b/web/app.py
@@ -110,6 +110,24 @@ tpl.filters["safe_html"] = safe_html_filter
tpl.filters["dt"] = dt_filter
+def _archive_href(year: str = "", month: str = "", nodate: bool = False, page: int = 1) -> str:
+ """URL ссылки архива: '/published' без параметров или с query (страница 1 не пишется)."""
+ parts = []
+ if year:
+ parts.append(f"year={year}")
+ if month:
+ parts.append(f"month={month}")
+ if nodate:
+ parts.append("nodate=1")
+ if page and page > 1:
+ parts.append(f"page={page}")
+ qs = "&".join(parts)
+ return f"/published?{qs}" if qs else "/published"
+
+
+tpl.globals["_archive_href"] = _archive_href
+
+
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
@@ -136,6 +154,41 @@ def _migrate():
_migrate()
+# --- Архив опубликованных: дерево навигации по годам/месяцам ---
+MONTH_NAMES_RU = ("", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
+ "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек")
+ARCHIVE_PAGE_SIZE = 10
+
+
+def _archive_tree(conn: sqlite3.Connection) -> dict:
+ """Дерево навигации архива: {years: [{year, months:[{m, label, n}], n}], no_date: n}.
+ Строится только из существующих периодов (GROUP BY); пустые месяцы отсутствуют —
+ как в Hugo-архивах, где дерево порождается постами, а не календарём."""
+ 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'])]}",
+ "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}
+
+
def _session_ok(request: Request) -> bool:
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
@@ -733,19 +786,50 @@ def post_translate(post_id: int, request: Request,
@app.get("/published", response_class=HTMLResponse)
-def published(request: Request):
+def published(request: Request, year: str = "", month: str = "", nodate: str = "", page: int = 1):
+ """Архив опубликованных: дерево навигации по годам/месяцам (правая колонка),
+ посты за выбранный период, пагинация по 10."""
_require_auth(request)
+ page = max(1, int(page or 1))
conn = _db()
+ tree = _archive_tree(conn)
+
+ filters, params = [], []
+ filters.append("p.status='published'")
+ if nodate:
+ filters.append("(p.published_at IS NULL OR p.published_at='')")
+ else:
+ filters.append("(p.published_at IS NOT NULL AND p.published_at != '')")
+ if year:
+ filters.append("strftime('%Y', p.published_at) = ?")
+ params.append(year)
+ if month:
+ filters.append("strftime('%m', p.published_at) = ?")
+ params.append(month)
+ where = " AND ".join(filters)
+
+ total = conn.execute(f"SELECT COUNT(*) FROM posts p WHERE {where}", params).fetchone()[0]
+ pages = max(1, (total + ARCHIVE_PAGE_SIZE - 1) // ARCHIVE_PAGE_SIZE)
+ page = min(page, pages)
+
rows = conn.execute(
- """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 p.status='published' ORDER BY p.published_at DESC LIMIT 100"""
+ 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 (SELECT post_id, MAX(id) mid FROM published GROUP BY post_id) lastpub
+ ON lastpub.post_id=p.id
+ LEFT JOIN published pub ON pub.id=lastpub.mid
+ WHERE {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)
+
+ html = tpl.get_template("published.html").render(
+ posts=rows, tree=tree, year=year, month=month, nodate=bool(nodate),
+ page=page, pages=pages, total=total,
+ )
return HTMLResponse(html)
diff --git a/web/templates/published.html b/web/templates/published.html
index 8d55c43..23bc424 100644
--- a/web/templates/published.html
+++ b/web/templates/published.html
@@ -1,43 +1,137 @@
{% extends "base.html" %}
-{% block title %}Опубликовано{% endblock %}
+{% block title %}Архив — Опубликовано{% endblock %}
{% block content %}
-