Newer
Older
gnexus-tasks / docs / TZ.md

Техническое задание: gntodo

Персональный менеджер задач для повседневных дел и личных проектов.

Версия ТЗ 0.91
Дата 2026-10-10
Статус На обсуждении

1. Общие сведения

1.1. Назначение

Продвинутый личный таск-менеджер. Ключевая идея — минимальный трением ввод: задача закидывается в систему в одну строку, без выбора проекта, тегов и приоритета. Система сама делает предварительную детализацию, а пользователь позже утверждает или правит её при разборе.

1.2. Пользователи

  • Несколько пользователей: данные (задачи, проекты, теги, документы, сад, XP/монеты, настройки) полностью изолированы по user_id — один пользователь не видит чужого. Сад (0.58) в интерфейсе отключён, но его данные по-прежнему изолированы.
  • Авторизация — через централизованный SSO-сервер auth.gnexus.space с клиентской библиотекой gnexus-gauth (см. 6.4). Пользователь создаётся в БД при первом входе (email, avatar, locale — из SSO).
  • Claim при первом логине (миграция на мультиюзерность): все строки с user_id = NULL (наследие однопользовательской версии) и глобальные настройки забираются первым вошедшим пользователем.
  • Косвенные «пользователи» — ИИ-агенты, действующие от имени своего пользователя через MCP (3.10).

1.3. Развёртывание

  • Собственный сервер (VPS), доступ из интернета.
  • HTTPS, реальный домен; TLS терминирует внешний reverse-proxy VPS — docker-compose (web-контейнер) слушает :80 за ним.
  • Данные хранятся только на своём сервере.

2. Сценарии использования

Сценарий А — веб-интерфейс (основной). Разбор стека входящих, детализация и утверждение задач, работа с проектами, планирование, просмотр дерева задач.

Сценарий Б — ИИ-агент через MCP. Агент от имени пользователя добавляет задачи (например, из переписки или диалога с LLM). Большинство AI-задач выполняется на стороне агента; сервер предоставляет MCP-интерфейс к данным.

3. Функциональные требования

3.1. Быстрый захват задач (стек входящих)

  • Задача создаётся одним текстом (заголовок). Выбор проекта, тегов, приоритета — не обязателен и не предлагается принудительно при вводе.
  • Массовое добавление (0.39): после сохранения полной формы создания она не закрывается, а очищается (выбранный проект сохраняется, фокус в заголовок) — можно вводить следующую задачу. Страница за формой обновляется (глобальный «+» с любой страницы, кроме страницы задачи, переводит её к созданной задаче).
  • Проект из контекста: на странице проекта новая задача по умолчанию относится к нему; на странице задачи — к её проекту (подзадачи наследуют проект родителя).
  • Такие «сырые» задачи попадают в стек входящих (raw) и ожидают детализации.
  • Стек — отдельное представление с количеством входящих, видимое с главного экрана.

3.2. Автоматическая предварительная детализация

При попадании задачи в стек система самостоятельно предлагает черновые метаданные (0.40: только оформление задачи, без планирования):

  • короткий заголовок — длинный/перегруженный заголовок сжимается до лаконичного названия; выброшенное не теряется (0.46): условия, числа и уточнения, убранные из заголовка, переносятся в описание; если модель сократила заголовок, но описания не предложила, — в описание автоматически кладётся исходный заголовок (страховка от потери информации, при отключённом описании — не срабатывает);
  • описание с нумерованными шагами — если шаги выполнения очевидны;
  • теги — максимум 3: подходящие из существующего справочника или 1–2 новых; новые теги — только на английском;
  • LLM отвечает на языке исходного текста задачи.

Планирование ИИ не делает (решение 2026-09-22, «0% попадания»): проект, приоритет и оценку длительности LLM не предлагает — их заполняет пользователь.

Требования:

  • Это не окончательная детализация — промахи ожидаемы и нормальны.
  • Заполненные пользователем поля не угадываются (0.40): при первичном анализе предлагается только недостающее (заголовок — всегда, сжатие — главная работа; описание и теги — только если их нет). «Переспросить ИИ» (redetail) — явный запрос полного анализа, он может перепредложить всё.
  • При разборе задачи в стеке доступны варианты:
    1. «Да, всё верно» — принять предложенные метаданные одним действием (теги предложения добавляются к назначенным, пользовательские не снимаются);
    2. отредактировать — изменить предложенное вручную, затем утвердить.
  • Задача считается утверждённой (approved) только после явного действия пользователя.
  • Ответ LLM валидируется: строки-заглушки («NULL», пустые/пробельные, «n/a») отбрасываются, длина ограничивается, дубли тегов схлопываются.
  • Механизм предсказания — см. 3.11 и раздел открытых вопросов.

3.3. Содержание и редактирование задачи

  • Описание задачи — Markdown (редактирование с форматированием).
  • Вложения — изображения, в том числе вставка из буфера обмена (Ctrl+V прямо в редактор). Вставка работает и в форме создания (вложение привязывается к документу: при создании задачи откладывается до первого сохранения — в поле сначала blob-плейсхолдер с живым превью, после создания файлы грузятся и ссылки меняются на настоящие); в заметке проекта при отсутствии документа он материализуется текущей заметкой (0.52).
  • Отображение Markdown в описании (просмотр и редактирование).
  • Чекбоксы списков задач (- [ ] / - [x]) в отображаемом описании кликабельны: клик переключает пункт и сохраняет статус в markdown описания.
  • Инлайн-редактирование на странице задачи: каждое редактируемое поле правится прямо на месте (клик по значению → редактор на его месте, ✓/✗ или Esc); пустые поля тоже показываются («— не указано») и редактируются кликом. То же — на странице проекта (название, заметка, актуальность, приоритет). Полный режим правки (вся форма целиком) сохраняется наряду с инлайном. Открытие редактора ставит фокус в первое поле (клик по значению = «начать печатать»), а Esc отменяет правку независимо от того, где сейчас фокус (0.90): без этого Esc работал, только пока фокус оставался внутри редактора, — после клика по соседней строке он молча перестаёт действовать.
  • Длительность — часами и минутами (0.90): оценка и факт вводятся двумя полями («ч» и «мин»), а не переводом в минуты. Пока в полях печатают, они источник истины: пришедшее от страницы значение применяется, только если не совпадает с уже набранным (иначе очищенное поле мгновенно наполнялось прежним числом, и переписать значение было нельзя). Границы — как у API (1…1440 мин): выход из полей сворачивает минуты в часы («0 ч 90 мин» → «1 ч 30 мин») и зажимает значение, а не сервер отвечает 422 после нажатия ✓.
  • README вместо описания-ссылки (0.41): если описание задачи состоит из одной ссылки на git-репозиторий, вместо неё показывается README.md из репозитория (с подписью-ссылкой на источник). Хост не обязан быть GitHub/GitLab — селф-хостед системы ищутся эвристикой (API github/gitlab/gitea + raw-пути web-интерфейсов, дефолтная ветка HEAD); не нашли — остаётся ссылка. Сырой текст в БД не меняется; кэш прочтённого README — 10 минут. Пользователю подсказывается об опции: в форме создания задачи (подпись редактора) и в пустом описании/инлайн-правке на странице задачи. У проекта этого механизма больше нет (0.67): ссылка на репозиторий живёт отдельным полем, а README показывается вторым описанием (см. 3.8).
  • Линки описания/заметки — в новую вкладку (0.41): всё, что рендерится из Markdown (описание, заметка, README), открывается во внешность (target="_blank", rel="noopener") — PWA не ловит навигацию в своё окно.
  • Длинная заметка сворачивается (0.41): если блок заметки (или README из ссылки, а с 0.67 — и README репозитория проекта) выше ~420px, показывается начало + подпись «Заметка большая — показано начало» и кнопка «Развернуть» (обратно — «Свернуть»).

3.4. Статусы, дедлайны, прогнозы, бюджет

Тип задачи: каждая задача имеет тип — разовая или регулярная (повторяющаяся; см. 3.5).

Статусы выполнения:

Статус Смысл
to_do — К выполнению Принята в работу, ожидает выполнения
in_progress — В работе Выполняется сейчас
done — Завершено (закрыто) Выполнена
cancelled — Отменено Была актуальной, затем перестала быть актуальной. Не равно удалению — задача сохраняется, т.к. может снова стать актуальной
deferred — Отложено На текущий момент не нужна или не в приоритете; возможно, позже это изменится
  • Статус cancelled и deferred — обратимы: задача может быть возвращена в to_do.
  • Удаление — отдельное явное действие, не статус.
  • Детализационный жизненный цикл (raw → approved) — отдельная плоскость, не статус выполнения: «сырая» задача из стека получает статус только после утверждения.
  • Приёмка агентской работы (accept_state: нет | pending | accepted | rejected) — вторая отдельная плоскость (3.20): закрытие задачи ИИ-агентом помечается «ждёт приёмки», владелец принимает работу или возвращает задачу в работу. Набор статусов выполнения при этом не меняется.

Дедлайны — двух видов:

  • строгий: «сделать до конкретной даты»;
  • нестрогий: «сделать в течение недели / месяца / года» — не привязан к конкретному дню, задаётся периодом.

Прогнозирование времени выполнения — система оценивает длительность задачи (на основе истории завершённых задач; алгоритм уточняется).

Бюджет — опциональный, на задачу; есть не у каждой задачи:

  • задаётся пользователем вручную (деньги и/или время);
  • рядом с бюджетом отображается оценка затрат по задаче, чтобы сразу видеть: задачу можно взять в работу или на неё не хватает денег.

3.5. Регулярные задачи

  • Отдельный тип задачи — регулярная (повторяющаяся) в противовес разовой.
  • Повторение создаёт новую задачу по правилу после закрытия предыдущей (закрытие — единственный момент, когда рождается следующий экземпляр: отмена и «отложено» цепочку не продолжают).
  • Правила повторения (реализованы с 0.4): интервал — каждые N дней от даты создания, дни недели — ближайший следующий из выбранных, месячное число — указанное число месяца (нет такого дня — последний день месяца). Отсчёт идёт по календарю, а не «через N суток после выполнения».
  • Новый экземпляр наследует доступность для ИИ-агента (3.20) от предыдущего: цепочка регулярной не теряет мандат.
  • Дата следующего экземпляра видна сразу (0.91). Она вычисляется тем же правилом, что и рождение экземпляра, и до 0.91 выбрасывалась: закрыв ежедневную задачу утром, владелец видел её же снова «к выполнению», без даты и неотличимо от задачи на сегодня. Теперь она ложится в существующее поле deadline_date (отдельной сущности и миграции нет). Поле переиспользуется осознанно: у регулярной это «когда пора», а не срок работы, поэтому напоминания о сроках (3.21) её не касаются, а подпись даты в интерфейсе читается как «след. 12.10.2026». Если у экземпляра задан нестрогий период («в течение недели»), дата не ставится: 3.4 запрещает оба поля разом, а период у регулярной осмыслен сам по себе.
  • Метка в интерфейсе (0.91): в карточке задачи (3.6) регулярная отмечена акцентной полосой по левой кромке и кружком с иконкой повтора перед названием — задача опознаётся по типу, не читая заголовок и бейджи; подсказка кружка говорит правило словами и называет дату следующего экземпляра.

3.6. Представления задач

  • Классический вид: список с фильтрами (статус, приоритет, тег). Шкала приоритета — 0-10 с градациями (очень низкий…срочный, 3.13); задача без приоритета считается «очень низким». В интерфейсе фильтр приоритета — включение «приоритетные» (выше среднего, то есть high и urgent), отсечка по градациям остаётся в API.
  • Статус — табами, тег — поиском по имени (0.70): выпадашки не помогали при росте данных. Пять статусов спрятаны в селекте — теперь это табы («Все» + пять статусов, иконки как в бейджах статусов); на узком экране табы прокручиваются по горизонтали внутри своего ряда, страница по горизонтали не едет. Тегов у пользователя десятки — найти нужный в селекте глазами нельзя, поэтому тег задаётся поиском по подстроке имени (без учёта регистра): список задач сужается по мере ввода, а не после выбора тега из подсказок; пустой ввод фильтр не применяет. Подсказки (совпавшие теги) показывает GnCombobox кита. Доработка 0.71: подсказки показываются только под набранное — на фокусе пустого поля вместо простыни из всех тегов стоит подсказка «начните вводить название тега». Внутри поля, справа — крестик сброса: у поиска нет пункта «— любой —», а фильтр ещё и запоминается (3.8), поэтому снять его иначе было нечем; пустой ввод после сброса применяется сразу, без задержки ввода. Доработка 0.73: фильтра по проекту в списке нет — разрез по проектам это раздел «Проекты» (3.8), в общем списке селект только дублировал его (со страницы ушла и загрузка списка проектов — она была нужна лишь этому фильтру). Подписи у поля тега нет (0.73): табы подписаны собой, а у поля есть иконка тега и подсказка; поле при этом стало 300px, как на странице проекта (0.71), иначе имя тега в нём не читалось. Доработка 0.74: фильтр приоритета — один переключатель «Приоритетные» (GnSwitch кита) вместо табов приоритета (они прожили только 0.73): из шести пунктов нужен был ровно один вопрос — «показать главное». Включённый переключатель оставляет задачи выше среднего: градации high и urgent (7-10 по шкале 3.13). Задача без приоритета (very_low, пустое значение) в отсечку не попадает — она и есть самая низкая. Переключатель стоит рядом с полем тега (с 0.75 — в шапке страницы, см. ниже) и выровнен по центру поля: короткий контрол при выравнивании по низу липнул бы к нижнему краю 50-пиксельной коробки. Доработка 0.76: в шапке выровнены и сами элементы друг относительно друга — у кита под полем остаётся margin-bottom 15px у form-group, а над полем 8px у input, из-за чего поле стояло на 3.5px выше центра ряда; в фильтре эти отступы сняты, и обёртка поля совпадает с его коробкой. Крестик сброса отсчитывается от низа обёртки, поэтому до 0.76 «уплывал» к нижнему краю поля на те же 15px.
  • API списка принимает подстрочный фильтр тега параметром tag у GET /api/tasks (0.70; регистронезависимо, служебные знаки % и _ во вводе экранируются — «100%» не превращается в маску) и отсечку по нижней границе приоритета параметром priority_min (0.74; целое 0-10, вне шкалы — 422), которой работает переключатель «Приоритетные» (priority_min=7). Параметры по id/градации (tag_id, priority) остаются — их держат тесты и старые клиенты: MCP-инструмент list_tasks фильтрует по тегу своей выборкой в БД, REST-параметр не использует. Задачи без приоритета (NULL) отсечку priority_min не проходят.
  • Фильтр по типу задачи (0.91): параметр task_type у GET /api/tasks (one_time | recurring, иное значение — 422), которым работает переключатель «Регулярные» — второй GnSwitch рядом с «Приоритетными» в шапке списка (3.5): «показать главное» и «показать повторяющееся» — два вопроса об одном списке, и читаются они парой. Фильтры списка применяет сервер (как и остальные), персистентности у них нет — по 3.8 запоминаются фильтры только на странице проекта.
  • Фильтры-поля — в шапке страницы (0.75): переключатель «Приоритетные» и поиск по тегу собраны справа от заголовка (слот действий GnPageHeader) одной группой; отдельной строкой под шапкой остались только табы статуса. На узком экране группа переносится по строкам сама (переключатель строкой, поле — следующей).
  • Пагинация: длинные списки (список задач, стек на главной, задачи проекта, проекты, архив) разбиваются на страницы по 30 элементов; навигация кнопками (‹ номера ›, с усечением «…» и подписью «Страница N из M»). Смена фильтров возвращает на первую страницу; при одной странице контрол не показывается.
  • Подзадачи показываются карточками внутри задачи и в списке; представление «дерево» убрано (проба 0.5 показала, что оно мешает — возможен возврат позже). Связь подзадачи с родителем (parent_task_id) в модели данных сохраняется.
  • Карточки списка задач раскладываются сеткой по три в ряд (0.68; на десктопе контейнер расширен до 1440px, см. 4): до 1200px — две колонки, до 900px — одна (там список остаётся колонкой флексом). Скелетон загрузки повторяет ту же сетку, чтобы загрузка не дёргала раскладку.
  • Карточка задачи кликабельна целиком (0.75): клик мимо внутренних элементов ведёт на страницу задачи. Свои интерактивные элементы карточка не перехватывает — заголовок-ссылка, бейдж проекта (с 0.75 это тоже ссылка, на страницу проекта; при наведении бейдж подсвечивается акцентом) и «⋯»-меню обрабатывают клик сами — иначе получалась бы двойная навигация. Выделенный в карточке текст кликом не считается: иначе не скопировать сниппет. Курсор и подсветка рамки при наведении — как у карточки проекта.
  • Свежесть задачи на карточке (0.75): когда задача добавлена, словами («Сегодня», «Вчера», «3 дня назад», «2 недели назад» — тот же timeAgo, что у свежести проекта, 0.69); полная дата — в подсказке. Это контекст, а не метаданные, поэтому не бейдж, а приглушённый текст; на странице задачи дата добавления как была — в сайдбаре «Даты» в полном формате. С 0.76 живёт в подвале карточки (слот footer китового GnCard), а не в ряду бейджей: подвал отделён чертой 1px и прижат к низу карточки, поэтому в сетке списка он у всех карточек на одной линии. Иконка рядом с текстом идёт с китовым классом normalize — без него глиф иконочного шрифта садится на 1.7px выше середины строки (в подвале карточки сама поправка снята с 0.83 — см. 3.19).
  • Проект задачи — из меню карточки (0.82): в «⋯»-меню карточки задачи рядом стоят «Перейти к проекту» и «Переместить в проект» — это один и тот же вопрос «в каком проекте задача». «Перейти» показывается у задачи с проектом и не показывается на странице самого проекта (там задача и так лежит в нём). «Переместить» открывает модалку с выбором проекта: GnSelect кита, первым пунктом «— без проекта —», текущий проект выбран по умолчанию; задача в архивном проекте тоже остаётся выбранной, хотя в справочнике активных проектов её нет. Перенос уходит тем же PATCH /api/tasks/{id} с project_id, что и правка поля «Проект» на странице задачи: бэкенд проверяет, что проект принадлежит пользователю, и рассылает task.changed. XP и монеты за перенос не начисляются — они висят только на переходе в «завершено». Успех подтверждается тостом, список перечитывается сразу (moved), поэтому задача тут же уходит со страницы прежнего проекта. В 0.83 пункт сокращён до «В другой проект»: надпись «Переместить в проект» в китовом меню шириной 220px рвалась на две строки.

3.7. Главная страница (дашборд + стек)

  • Главная совмещает два блока: дашборд активного сверху и стек входящих снизу.
  • Дашборд: «В работе», «Просрочено», дедлайны на неделю, «Следующие» (to_do по приоритету, топ-5); сайдбар — сводка по статусам и активные проекты с прогрессом.
  • Пустые секции дашборда не показываются; при отсутствии и активных, и проектов — нейтральное пустое состояние.
  • Карточки стека раскладываются сеткой по три в ряд (0.59): на узких экранах — две и одна (ломающиеся точки 1080 и 720px), карточки разной высоты выравниваются по верху, а не растягиваются в ряду; скелетон загрузки повторяет ту же сетку.

3.8. Проекты

  • Статус актуальности проекта (активен / приостановлен; «закрыт» выражается архивом, см. 3.8.1).
  • Приоритет проекта.
  • Заметка к проекту: Markdown-поле для ссылок на ресурсы проекта и прочего контекста; заметка — тот же «документ», что и описание задачи: в неё можно вставлять изображения из буфера (Ctrl+V).
  • Проекты без задач — отдельной секцией в конце списка (0.60): на карточке бейдж «Задач закрыто 0/0» ничем не отличался от заполненных, и пустой проект терялся среди остальных. Список проектов делится на секции «С задачами» и «Без задач» (иконка ph-folder-dotted), счётчики в заголовках — по всему списку; заголовки секций показываются только когда пустые проекты есть, иначе список выглядит как раньше. Порядок применяется до пагинации (3.6), чтобы секция не разъезжалась по страницам.
  • Карточки проектов раскладываются сеткой по три в ряд (0.68; в 0.61 их было две): в три колонки список читается компактнее и помещается в широкий контейнер (1440px, см. 4), а на узких экранах — две колонки до 1200px и одна до 860px. Заголовок секции занимает всю ширину строки; скелетон загрузки повторяет ту же сетку. Карточки ряда одной высоты с подвалом по низу (0.66; в 0.61 они выравнивались по верху и подвал «плясал» между соседями).
  • Карточка в списке — метрики, без заметки (0.62): заметка (Markdown с ресурсами) остаётся только на странице проекта — в списке она делала карточки разной высоты и мешала сравнивать проекты. В шапке карточки справа — актуальность; ниже ряд метрик: приоритет, «N в работе», «просрочено N» (нулевые метрики не показываются — иначе ряд бейджей шумит одинаковыми «0»); полоса прогресса «Закрыто N из M» с процентом (оформление полосы изменено в 0.64); подвал виден всегда (0.69) — слева свежесть словами, справа ближайший дедлайн по открытым задачам (просроченный красным). У проекта без задач вместо полосы — подсказка «Задач в проекте пока нет». Метрики считаются по всем задачам проекта (как и полоса прогресса), просрочка — по незакрытым.
  • Блок задач на карточке — счётчики и клетки по задачам (0.64): подпись «Закрыто N из M» заменена счётчиком «✓ N / M» (закрытых — крупнее, всего — тише; полная фраза осталась в подсказке при наведении), процент кита — справа. Полоса разбита насечками: одна клетка — одна задача, закрытые клетки залиты цветом-меткой проекта (у проекта без цвета — акцент кита). Клетки рисуются при 2..24 задачах: у одной задачи дробить нечего, при большем числе клетка становится уже 4px и полоса рябит — там она сплошная. Заливка считается по клеткам (100%/M × N), а не процентом кита: у процента округление до целого, и на границе клетки оставалась бы щель. Трек на карточке тоньше китовских 18px — 8px (0.68): полоса здесь подложка под счётчиком, а не самостоятельный элемент. Проект закрыт целиком — полоса и счётчик уходят в зелёный кита. Цвет проекта в тексте не используется (тёмная ступень цветных рядов и тёмный край нейтральной шкалы, 0.72 — нечитаемы как текст) — им закрашена только полоса.
  • Метка проекта — кружок и фавикон сайта (0.65): в шапке страницы проекта и на карточке списка проект помечен парой «цвет-кружок + иконка сайта»: на карточке кружок 16px и фавикон 18px (было 9px без иконки), в шапке страницы — 18px и 21px при H1 23px. Метка идёт размером от кегля имени (в карточке база — тайтл), поэтому растёт вместе с ним. Иконку тянет тот же бэкенд-прокси (3.11): нет сайта или иконки — остаётся один кружок, битой картинки нет (404 — штатный ответ). В шапке страницы фавикон — ссылка на сайт (короткий путь к нему рядом с именем), на карточке — просто картинка: карточка целиком ссылка, вложенные ссылки невалидны (0.63). Заодно тайтл карточки списка крупнее (16px вместо 14px, приходивших наследованием): имя проекта — главный ориентир в списке.
  • Ссылка на сайт — чип в шапке проекта (0.65): прежняя подпись (13px приглушённым цветом, без рамки) выпадала из ряда бейджей и читалась подписью, а не действием. Теперь это чип геометрии бейджа кита (высота 24px, радиус 3px, 13px/600) с иконкой сайта 20px и подсветкой акцентом при наведении и фокусе; капса нет — домен в верхнем регистре читается хуже. Иконки нет — глобус. Обводку чип не носит (снята в 0.65 по просьбе пользователя): рамка вокруг ссылки-действия читалась как второй бейдж в ряду.
  • Карточка списка кликабельна целиком (0.63): кнопка «Открыть» дублировала заголовок-ссылку и занимала подвал карточки. Теперь карточка — обёртка-ссылка (семантичный <a>: клик в любом месте, фокус с клавиатуры, «открыть в новой вкладке»), заголовок внутри — не ссылка (вложенные ссылки невалидны). В архиве (3.8.1) кнопка «Открыть» остаётся — там рядом действия восстановления/удаления.
  • Цвет-метка проекта (0.63): у проекта может быть цвет-кружок — «узнаваемость» проекта в списках. Поле необязательное: новым проектам цвет не назначается, пока пользователь сам не выберет (пусто — кружка нет). Выбор — палитра 27 оттенков из 8 базовых тонов (3.19), на странице проекта (инлайн-строка «Цвет») и в форме правки; в БД — HEX #rrggbb (палитра — представление фронта, бэкенд проверяет только формат, поэтому цвет из MCP вне палитры тоже валиден и показывается отдельной клеткой). Показывается в трёх местах: карточка в списке проектов, шапка страницы проекта, метка проекта у задач (списки, дашборд, страница задачи). В фильтрах и селектах проектов цвета нет: в выпадашке это шум, а не узнаваемость. Базовая палитра уточнена в 0.69: ряд «песочного» (warning кита, оттенок 35°) заменён своим жёлтым (#e5cf5a, оттенок 50°) — рядом с оранжевым (25°) он читался вторым оранжевым, а не жёлтым; жёлтого тона в ките нет вовсе, поэтому база своя. Добавлен ряд нейтральных от белого — он последний в сетке: цветные метки узнаются быстрее, а серые нужны редко и на своём месте не мешают. Палитра пересобрана в 0.72 по замечанию «слишком одинаковые цвета»: клеток стало меньше, но любые две клетки сетки различимы. Убраны приглушённые варианты (тот же тон с половинной насыщенностью) — именно они и давали «одинаковые» клетки: и рядом со светлой ступенью своего ряда, и с приглушёнными чужих тонов. Из базовых тонов убран голубой #7dcfff (199°): он стоял в 12° от циана и в 20° от синего — три холодных клетки читались одним синим; добавлен коричневый #8c5e35 (28°, светлота 38%, насыщенность 45%) — коричневого тона в ките нет, и это свой цвет, а не «тёмный оранжевый». Ступени считаются от светлоты базового (светлая +18%, тёмная −24%), а не заданы абсолютной шкалой: у тона, стоящего у предела светлоты (36..88%), ступени просто нет, поэтому ряды коричневого и сиреневого короче — это честнее клетки, не отличимой от соседней. Разница измеримая: минимальная попарная разница по CIELAB — ΔE 12.5. Ряд нейтральных сокращён с 12 ступеней до 5 через 16%: от белого #ffffff до #5c5c5c. До чёрного шкала не доходит намеренно: чёрная метка на тёмной карточке кита не видна, то есть была бы выбором без результата (раньше это называлось ценой полноты шкалы).
  • Сайт проекта (0.63): ссылка на сайт проекта — отдельное поле, отдельно от заметки (в заметке ссылка тонула в тексте). Показывается только на странице проекта: в шапке — имя хоста с иконкой сайта, клик открывает сайт в новой вкладке (вид ссылки уточнён в 0.65); та же ссылка правится строкой «Сайт» в параметрах и в форме правки. На карточке списка ссылки нет (карточка целиком ведёт на проект), но иконка сайта стоит в метке проекта (0.65). Иконку тянет бэкенд-прокси с кэшем (3.11); если её нет — глобус. Поле необязательное.
  • Репозиторий проекта (0.67): ссылка на git-репозиторий — отдельное поле repository_url, отдельно и от заметки, и от сайта. Раньше её клали в заметку, и она блокировала саму заметку (заметка из одной ссылки подменялась README): у проекта теперь два описания — заметка остаётся заметкой, а из репозитория подтягивается и показывается README как дополнительное описание. Механизм распознавания ссылки в заметке проекта снят (у задачи README-вместо-описания остаётся, см. 3.3). README показывается карточкой «README из репозитория» под заметкой: в шапке карточки — ссылка на репозиторий (хост и путь без схемы, открывается в новой вкладке), в теле — отрендеренный Markdown; длинный README сворачивается тем же порогом ~420px (3.3). Тянет его бэкенд-прокси /api/projects/{id}/readme с кэшем 10 минут (данные не покидают VPS — как у фавикона, 3.11): привязка к проекту, а не ?url=, чтобы эндпоинт не стал открытым прокси для чтения произвольных адресов. README в репозитории не нашёлся — тихая подпись «README не найден» (поле необязательное, проект живёт и без него). Правится строкой «Репозиторий» в параметрах проекта и в форме правки. Форма ссылки не проверяется (кроме http(s) с хостом): не похожа на репозиторий — прокси честно ответит «не найден». Существующие заметки-ссылки в БД не переписываются: ссылку нужно указать в новом поле.
  • Закрепление проектов (0.66): у проекта есть булавка pinned — важные проекты держатся наверху списка и не уезжают вниз с пагинацией. Закреплённые живут отдельной секцией «Закреплённые» вверху (иконка ph-push-pin, янтарный бейдж-счётчик), независимо от наличия задач: закреплённый проект без задач остаётся в «Закреплённых», а не в «Без задач». Переключателей два, поле одно: булавка в правом верхнем углу карточки списка — рядом с бейджем актуальности (шапка сдвигается левее), видна всегда, в покое не приглушена; и строка «Закреплён» в параметрах страницы проекта (значение кликабельно, как у остальных строк). Место булавки переставлено в 0.67: в правом нижнем углу карточки она читалась частью подвала, а на странице проекта её и вовсе не находили — поэтому у имени проекта появилась кнопка-булавка рядом с названием (в архиве скрыта: страница только для чтения, состояние видно строкой «Закреплён»; строка в параметрах остаётся — второй переключатель). Новый проект создаётся незакреплённым. В MCP — поле pinned у update_project (3.10).
  • Сортировка списка по свежести активности (0.66): выше те проекты, где недавно что-то происходило. Порядок — закреплённые → с задачами → без задач, внутри группы по убыванию активности, затем по id. Активность — самая поздняя из задач проекта (created_at или done_at); правки самого проекта (заметка, цвет, статус) активностью не считаются — сортировка про работу над проектом, а не про правку его карточки. Проект без задач стоит в конце своей группы. Порядок применяется до пагинации (3.6), а счётчики секций и показ заголовков считаются по всему списку, не по странице: иначе на второй странице заголовки то появлялись бы, то исчезали.
  • Подвал карточки — отсчёт до дедлайна (0.66): ближайший срок показывается словами — «сегодня», «завтра», «через 3 дня», «просрочено на 5 дней» (просрочка красным), полная дата уходит в подсказку при наведении: дата без отсчёта требовала сравнения с календарём на каждом проекте. Формы числа — по правилу локали (0.69, как у подписи свежести, см. ниже): в 0.66 это было «через N дн.» — сокращение из-за отсутствия плюрализации, теперь формы полные. Дедлайн отсчитывается по календарным суткам (без часовых поясов и перехода на летнее время). Красный в подвале — про сам ближайший срок, а не про наличие просроченных задач: бейдж «просрочено N» в ряду метрик остаётся независимым.
  • Подвал карточки виден всегда, слева — свежесть (0.69): подвал перестал быть местом только для дедлайна. Слева теперь свежесть проекта — когда последний раз была активность по задачам, словами: «сегодня», «вчера», «3 дня назад», «2 недели назад», «месяц назад», «4 месяца назад», «год назад» (та же активность, по которой сортируется список, 0.66: позднейшая из created_at/done_at задач; правки самого проекта не считаются); полная дата — в подсказке. Срок при этом стоит справа — важное в одном и том же углу у всех карточек, независимо от того, есть ли свежесть. Сутки календарные, ниже недели — дни, дальше округление вниз до крупной единицы (45 дней — это уже месяцы, 400 — годы). Появились плюральные формы: их выбирает правило самой локали (Intl.PluralRules) — «2 дня», а не «2 день»; в словаре формы записаны одной строкой через «|» (ru/uk — три, en — две), выбор формы в коде не хардкодится. Число при единственном числе остаётся («1 месяц назад»). У проекта без задач свежести нет — строка пустая, но место подвала сохраняется, чтобы низ карточек в ряду не разъезжался.
  • Фильтры задач проекта — статус табами, тег поиском (0.70): на странице проекта тот же форм-фактор, что в списке задач (3.6) — табы статуса («Все» + пять статусов, горизонтальная прокрутка на узком экране) и поиск по подстроке имени тега (с подсказкой на пустом вводе и крестиком сброса внутри поля, 0.71 — как в 3.6). Подписи у поля нет: фильтр на странице один, и он шире, чем в списке — 300px вместо 224px, иначе имя тега в нём не читалось (0.71). Тег здесь фильтрует список уже загруженных задач проекта — по имени, без учёта регистра. Выбранные фильтры (статус и текст поиска тега) запоминаются в localStorage отдельно для каждого проекта и пользователя: вернувшись на страницу, видишь те же фильтры. В приватном окне или после очистки данных страница просто работает на значениях по умолчанию (по статусу — «К выполнению»). Сохранённый прежними версиями выбор тега по id не восстанавливается осознанно: имя тега по id известно только после загрузки справочника.
  • Теплокарта активности проекта (0.83): на странице проекта, в сайдбаре рядом с «Статистикой», — та же сетка «как в гитхабе», что на странице статистики, но по задачам одного проекта: столбец — неделя (с понедельника), строка — день недели, заливка — по числу закрытых за день задач (шкала общая со статистикой: 1 / 2–3 / 4–6 / 7+). Дни без закрытий — пустые клетки, будущие дни — почти погашенные. В подсказке клетки — дата и оба числа: «закрыто N, создано M» (заливка про закрытия — это и есть работа по проекту, появления видны в подсказке). Период один — полгода, 26 недель (182 дня), переключателя периодов нет: в 0.83 периодов было два («за месяц» и «за полгода»), но полугодовая сетка в содержимое карточки влезает целиком, а месячная занимала пятую часть ширины и читалась недоделкой — в 0.84 переключатель убран. Бэкенд для неё не нужен: страница проекта и так держит все свои задачи, включая завершённые (3.6), поэтому история считается на клиенте — закрытия по done_at, появления по created_at. В содержимое сайдбара сетка влезает без горизонтальной прокрутки: ячейки 9px с зазором 2px (26 недель — 284px; у статистики ячейки 12px, но там карточка во всю ширину), легенда шкалы идёт под сеткой.
  • Теги проекта — облаком, клик по тегу фильтрует (0.83): список тегов в карточке «Теги» заменён облаком — теги идут подряд, а размер шрифта показывает, сколько задач с тегом (0.85–1.2em по ранжиру, точное число — в подсказке). Клик по тегу ставит его в фильтр (тот же поиск по подстроке имени, 0.70), клик по активному тегу фильтр снимает; активный тег подсвечен акцентом. До 960px сайдбар стоит ниже списка задач, поэтому после клика список подтягивается к верху экрана — иначе результат фильтра оказывался бы за кадром.
  • Задачи проекта — по две в ряд (0.83): карточки задач на странице проекта раскладываются сеткой по две в ряд (до 700px — одна колонка, как и раньше). Карточки одного ряда выравниваются по высоте, подвал прижат к низу.

3.8.1. Архив

Проект, с которым пользователь закончил работу, отправляется в архив — один архив на все проекты, отдельных архивов задач нет.

  • Архивация — с подтверждением («Отправить проект и его N задач в архив?»).
  • Архивный проект пропадает из рабочих видов: списка проектов, фильтров, выдачи задачи (3.9); его задачи не видны в активных списках задач. Задачи живут и уезжают в архив только вместе с проектом: отдельной архивации/удаления для задач внутри проекта нет (удаление отдельной задачи остаётся — гигиена).
  • Статистика и XP не пересчитываются: закрытия в архивных проектах остаются в истории.
  • Страница «Архив» (отдельный раздел меню): история архивных проектов — открытие проекта, все задачи, заметка. Восстановление возвращает проект со всеми задачами и статусами как были. Доступно и «удалить навсегда».
  • У архивного проекта страница доступна на чтение (с плашкой «в архиве»); создание задач и правки спрятаны.

3.9. Выдача задачи (бывший режим «3 вариантов»)

Анти-прокрастинационный режим: пользователь указывает доступное время в часах (например, «3», можно 0.5), система предлагает подходящие задачи; предложение — те же карточки, что в списке задач.

  • Выбор кандидатов учитывает: прогнозируемую длительность ≤ доступного времени, приоритеты, статус актуальности проектов.
  • Варианты должны быть осмысленно разными (не дубликаты).
  • Отдельной кнопки выбора/выполнения на странице выдачи нет: пользователь открывает карточку задачи (переход несёт ?via=options) и берёт её в работу на странице задачи; закрытие задачи, открытой из выдачи, помечается «выбрал и сделал».

3.10. MCP-интерфейс

  • Собственный MCP-сервер как часть backend.
  • Инструменты (первичный набор, расширяется):
    • create_task — добавить задачу (достаточно текста; метаданные опциональны);
    • update_task, complete_task;
    • list_tasks — фильтры по проекту (id или имени), тегу, статусу, подстроке;
    • get_task — полное описание с вложениями;
    • list_projects / get_project — обнаружение проектов и восстановление контекста проекта (заметка проекта + его открытые задачи); список отдаётся закреплёнными первыми (0.66), затем по имени — как в UI;
    • create_project — новый проект (имя, опционально заметка, приоритет, color, site_url и repository_url); дубликат имени отклоняется с подсказкой; заметка уходит в фоновую суммаризацию; микронаграда как при создании из UI;
    • update_project — переименование, заметка, relevance_status (active/paused), приоритет, color, site_url, repository_url, pinned (true — закрепить, false — открепить; 0.66); адресация по id или имени;
    • color (#rrggbb), site_url и repository_url (http(s)://host/...) обоих тудов — необязательные поля проекта (3.8); как принято в файле, None — «не менять», а пустая строка убирает значение; невалидный формат отклоняется с подсказкой формата (repository_url — репозиторий, из которого в интерфейсе подтягивается README вторым описанием, 0.67).
    • archive_project / restore_project — архив вместо удаления (0.45): проект прячется вместе с задачами из активных (история сохраняется), restore возвращает; вызовы идемпотентны; архивные проекты видны через list_projects(include_archived=True). Безвозвратного удаления проектов в MCP нет намеренно;
    • list_tags — справочник тегов (id для параметра tag_ids);
    • list_available_tasks — задачи, доступные ИИ-агенту (3.20): помеченные владельцем, в статусе to_do, со свободной арендой; уже взятая этим агентом задача остаётся в списке — у неё видно время аренды (0.85);
    • claim_task / release_task — взять задачу в работу (повторный вызов — продление аренды, а не второе взятие) и вернуть её, если она не по силам (0.85, 3.20);
    • complete_task — закрытие задачи; для действия от имени агента обязателен комментарий о том, что сделано (0.85). Тот же параметр comment у update_task(status="done") — закрытие идёт и через него.
  • Эргономика для агентов (в т.ч. небольших моделей):
    • серверные instructions: порядок работы, правило привязки задач к проекту, восстановление контекста через get_project после перерыва, запрет придумывать id, автоматика наград/спавна при закрытии;
    • адресация проекта по имени: параметры project_name (регистронезависимо) в тудах задач (create_task/update_task/list_tasks) и в тудах управления проектами (update_project/archive_project/ restore_project) наряду с project_id;
    • описания параметров в схеме каждого инструмента (enum значений, форматы дат, диапазоны приоритета);
    • ошибки подсказывают следующий шаг («call list_projects()», «найдите id через list_tasks(query=...)»); ответ create_task без проекта содержит подсказку.
  • В «Настройках» — раздел «Токены MCP»: генерация и отзыв персональных токенов (label + plaintext gnt_..., показывается один раз при создании; в БД только sha256-хэш). Там же ссылка на страницу-инструкцию по MCP (/mcp-help): что реализует, адрес сервера (совпадает с адресом приложения), токен, примеры подключения (Claude Code, универсальный JSON).
  • Аутентификация агентов — per-user токены (с 0.34): Bearer-токен из «Токенов MCP» → агент действует от имени своего пользователя и видит только его данные. Переменная окружения MCP_TOKEN удалена — старые конфиги агентов получат 401, пока владелец не создаст токен в «Настройках». С 0.42 Bearer-токен принимается и на REST /api/* — им ходит браузерное расширение (3.18).
  • Закрытие задачи агентом (update_task/complete_task) идёт по тому же общему пути, что и в UI: спавн регулярной, XP, монеты (3.5, 3.13); в открытых вкладках — скромный тост о награде (3.14). Растение больше не выдаётся — сад отключён в 0.58.
  • Актор действия (0.85, 3.20): у изменяющих тулов есть флаг is_user — по умолчанию действие считается агентским, is_user=true объявляет действие от имени владельца (тот же смысл у заголовка X-Actor: user на REST). Актор и канал (ui / api / mcp) попадают в журнал task_events. Без флага агент изменяет и закрывает только задачи, помеченные «доступно для ИИ-агента», и закрывает их в режим приёмки; флаг он может поставить лишь при создании задачи.

3.11. Локальные AI-функции

  • Автодетализация (3.2) выполняется маленькой LLM, запущенной локально через Ollama:
    • модель и параметры — конфигурацией приложения, без зашивки в код;
    • LLM оформляет текст задачи: короткий заголовок, описание с шагами, теги (из справочника или новые на английском), на языке исходного текста;
    • результат — черновые метаданные для утверждения (3.2), не окончательное решение;
    • планирование (проект/приоритет/время) ИИ не делает (0.40).
  • Суммаризация проектов (0.40): заметка проекта может быть большой для маленькой модели, поэтому для контекста автодетализации LLM видит projects.summary — краткое резюме заметки (суть + полезные признаки для классификации задач). Суммаризация генерируется той же LLM фоном при создании проекта с заметкой и при правке заметки (кэш в БД); без суммаризации в контекст идёт обрезка заметки.
  • Иконка сайта проекта (favicon, 0.63) — бэкенд-прокси с кэшем: фронт запрашивает /api/projects/{id}/favicon, бэкенд тянет иконку с сайта проекта и кэширует её на диске (7 дней; негативный результат «иконки нет» — 1 час, чтобы не долбить сайт). Показывается в метке проекта (карточка списка и шапка страницы проекта) и в чипе-ссылке на сайт (0.65). Сторонние favicon-сервисы не используются: адрес проекта не должен утекать третьей стороне (принцип «данные не покидают VPS»). Кандидаты: <link rel="icon"> из <head> сайта, затем /favicon.ico, /favicon.png, /apple-touch-icon.png. Тип определяется по magic-bytes (PNG/JPEG/GIF/ICO/WebP); SVG-иконки не принимаются — скриптуемый формат (как запрещённые mime вложений). Ограничения запроса — в 4.
  • README репозитория проекта (0.67) — такой же бэкенд-прокси с кэшем: фронт запрашивает /api/projects/{id}/readme, бэкенд читает repository_url проекта и тянет сырой README перебором известных путей (см. 3.3), кэшируя прочтённое на 10 минут (в том числе негативный результат — чтобы не долбить git-хост). Привязка к проекту, а не параметр ?url=: открытым прокси для чтения произвольных адресов эндпоинт не становится. Ссылку на репозиторий фронт не передаёт — её знает только бэкенд. По той же причине, что и у фавикона, сторонние сервисы чтения README не используются.
  • Все вызовы LLM — на собственном сервере, данные не покидают VPS.
  • JSON-колонки хранят кириллицу без \u-эскейпов (0.40): MCP-агенты читают читаемый текст (ensure_ascii=False).
  • Спекулярные LLM-сценарии вне метаданных — не в скоупе: большинство ИИ-задач выполняют внешние агенты через MCP (3.10).

3.12. Мультиязычность

  • UI поддерживает три языка: ru (базовый/fallback), en, uk.
  • Язык по умолчанию берётся из gnexus auth (profile.locale из SSO-профиля, scope profile).
  • В «Настройках» возможно ручное переопределение языка; значение хранится в глобальных настройках приложения (app_settings, ключ language), пустое значение = следовать SSO.
  • Даты, суммы и названия дней недели форматируются через Intl по активной локали.
  • Переводится только собственный UI: gnexus-ui-kit текстов не содержит (тексты через props).

3.13. Геймификация

Маленькая дополнительная награда за выполнение задач — мотивация без негативных последствий. Принцип только позитив: XP только начисляется, ничего не сгорает и не отнимается; стрики/серии (которые «ломаются» при пропуске) не используются.

  • XP за закрытие задачи: база 10 + бонус за приоритет (0–15) + бонус за объём по оценке времени (0–15). Начисление — один раз на задачу (идемпотентно); удаление задачи не отнимает заработанное. Хранение — таблица xp_events (поле kind разделяет события: task — закрытие, create_task / create_project — создание).
  • Награда за агентское закрытие (0.85, 3.20): XP и монеты начисляются сразу при закрытии задачи агентом — как за любое закрытие; ожидание приёмки награду не откладывает, а возврат работы её не отзывает (принцип «только позитив»).
  • Микронаграды за создание (без конфетти — скромный тост «+N XP • +M монет»): создание задачи — +2 XP и +2 монеты, создание проекта — +5 XP и +4 монеты (0.48: монеты ×2). В суммарный опыт (уровень) входят, растений в саду не рождают (сад отключён в 0.58 — выдача и так остановлена), счётчик закрытых задач не увеличивают; хранятся с task_id = NULL — удаление задачи или проекта награду не отнимает.
  • Ежедневная награда за визит: при открытии приложения фронт запрашивает POST /api/xp/daily — раз в сутки (граница дня UTC) начисляется 50 монет (только монеты, XP нет), повторные визиты в те же сутки молча пропускаются. Тост «+50 монет» только в день первого визита.
  • Уровень: растёт из суммарного XP (уровень не падает); прогресс до следующего уровня и название звания показываются на странице «Достижения» (0.58; ранее — в саду). Звания ботанические (Росток, Сеянцев, Садовник, … Легенда сада — 13 званий; порог уровня L = 100·L·(L−1)/2 XP). Дерево званий — модалка со списком всех уровней: звание и порог XP; пройденные отмечены, текущий подсвечен. В саду (код сохранён) та же модалка дополнительно показывает, что открывается на уровне — виды растений с ценой семени и расширения карты.
  • Момент награды: при закрытии задачи — короткая конфетти-анимация и тост «+N XP • +N монет» с похвалой. Особые закрытия (крупная задача — от 25 XP, «не хочется делать») — усиленное «золотое» конфетти. Редкость закрытия (см. ниже) в 0.58 в UI не показывается, но по-прежнему пишется в xp_events — как история для возврата сада.
  • Страница «Достижения» (/achievements, 0.58): весь видимый прогресс без сада. Бейджи шапки — уровень с названием звания, суммарный XP и монеты; полоса прогресса до следующего уровня; кнопка «Дерево званий»; справка «Как это работает»; ачивки с тирами (см. ниже) — тот же блок, что и в саду (общий компонент, логика не дублируется). Данные — GET /api/tasks, GET /api/xp, GET /api/xp/events; GET /api/garden не вызывается (это и есть механизм отключения выдачи растений, см. ниже). Страница слушает SSE xp.changed и task.changed. Пункт меню — между «Проектами» и «Статистикой» (место прежнего «Сада»), путь /garden редиректит на /achievements (старые ссылки и закладки PWA не ломаются).
  • Монеты: начисляются при закрытии задачи — xp целиком (0.48: было floor(xp / 2); заголовок X-Earned-Coins рядом с XP в тосте), за создание задачи/проекта и бонусом 50 × L при достижении уровня L (0.48: было 25 × L). Ежедневная награда не менялась (50 монет). Хранение — таблица coin_events (журнал заработка и трат; баланс = сумма amount). Траты — отрицательные события (source = shop); продажи нет («только позитив»). Баланс виден на «Достижениях» — GET /api/xp отдаёт поле coins (0.58). Пока сад отключён, тратить монеты негде: они копятся и пригодятся, когда сад вернётся.
  • Ачивки с тирами: у каждой ачивки ступени I/II/III (например, 10/50/100 задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, уровни, макс. задач за день, разные проекты, суммарные часы, строгие дедлайны, закрытия через выдачу задачи (см. 3.9), регулярная, проект. Ачивки считаются на фронте из задач и xp_events и показываются на «Достижениях» (0.58) — общий блок с садом (components/AchievementsPanel.vue). Описание по клику (0.49): карточка-ачивка — триггер GnPopover кита, клик открывает панель с кратким описанием условия (progress.achHint, i18n ×3; тултип по hover не годится — не работает на тач-экранах).
  • Связка с выдачей задачи (3.9): закрытие задачи, открытой из выдачи, помечается (earned_via) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой; XP при этом не меняется.
  • Страница «Статистика»: закрываемость за выбранный день / неделю / месяц (период листается стрелками назад/вперёд). Метрики — закрытые задачи, XP, созданные задачи, часы работы, «дни с результатом» — каждая со сравнением с предыдущим аналогичным периодом (дельта и проценты). Анимированный сравнительный график закрытых задач по бакетам периода (часы / дни недели / дни месяца): текущий и предыдущий период — пары столбиков. Сетка активности «как в гитхабе» за полгода или год (переключатель диапазона; цвет по закрытым задачам или XP, переключается). Данные считаются из xp_events и дат задач, без новых эндпоинтов.
  • Метка «ментально сложная»: у незакрытой задачи есть кнопка-переключатель «Не хочется делать» (warning-стиль) — она ставит метку «ментально сложная»: трудность не по объёму, а из-за требуемых волевых усилий. Метку можно снять до закрытия, после закрытия она не меняется. Закрытие такой задачи даёт +10 XP (сила воли) и всегда празднуется золотым конфетти; ачивка «Преодоление» (5/25/50 закрытых «ментально сложных») — на «Достижениях».
  • Справка «Как это работает»: кнопка на «Достижениях» (и, при возврате сада, на странице сада) открывает модалку с объяснением механик (XP, уровни, монеты, ачивки, принцип «только позитив»); параграфы — progress.about.
  • Сад отключён (0.58): сад не готов к использованию и недостаточно проработан, а сервис расширяется — приоритет отдан основному функционалу. Страница убрана из интерфейса, /garden редиректит на /achievements, код сохранён для возврата (по прецеденту представления «дерево», 3.6). Растения больше не выдаются: GardenItem создаётся лениво — в ensure_garden(), которую зовёт только GET /api/garden; UI этот эндпоинт не вызывает, поэтому выдача прекращается сама. Модель GardenItem, журнал coin_events и накопленная редкость (xp_events.rarity) сохраняются — сад вернётся с прежним состоянием. Всё описанное ниже — исторический блок (как сад устроен), актуальны только XP, уровни, монеты и ачивки.
  • Сад (отдельная страница): живая сцена — графическое представление сада с домиком в центре; вокруг сад на мелкой сетке (ячейка ~20px, привязка при перемещении, наложения разрешены). Каждая закрытая задача — растение в саду; сад только растёт, продавать и удалять растения нельзя. Растения и декорации можно перетаскивать (drag&drop). Рядом — уровень, кошелёк (баланс монет), «последний росток» (что и когда закрыто последним), история по месяцам (12 месяцев, столбики) и ачивки.
    • Отрисовка сцены — пиксель-арт на PixiJS 8 (WebGL): nearest масштабирование без сглаживания, целочисленный «нативный пиксель» (матрицы 16px × 2 = 32px на клетку). Все спрайты (тайлы травы/дикой земли, изгородь, домик, 13 видов растений × 3 стадии, декорации, кольца редкости, блёстки) генерируются из пиксель-матриц прямо в коде — внешних файлов-ассетов нет. Рендерер (src/game/GardenRenderer.ts) — обычный класс, не оборачивается во Vue-reactive (deep-прокси ломает внутренние identity-проверки Pixi); Pixi загружается динамическим импортом (отдельный чанк). Инвентарь, поповер и drag-ghost — DOM поверх канваса.
    • Перемещение объектов (drag&drop UX): зона захвата объекта — клетка (с запасом), а не тонкая текстура — промахи исключены. Фидбек жеста: «взял в руку» (лёгкое увеличение + подъём тени), подсветка целевой клетки по валидности (зелёная — можно, красная — нельзя, зеркало правил бэка: растениям запрещён домик, за краем сада подсветка скрыта), «приземление» — плавная доводка к центру клетки. Бросок мимо сада или на запрещённую клетку возвращает объект на исходную позицию анимацией без запроса к API (валидность фронт проверяет сам; бэк остаётся источником истины).
  • Две награды: опыт и монеты. (Исторический блок сада.) Опыт — «ранг садовника»: только растёт, ничего не открывает в магазине напрямую. Монеты — «валюта сада»: тратятся. Общий принцип: контент открывается за опыт, покупается за монеты — опыт (уровень) открывает виды растений и землю, монеты покупают рост растений, декорации и расширения карты.
  • Редкие растения: редкость решается в момент закрытия по весу задачи — чем больше XP, тем выше шанс редкого; эпик-растения — только у крупных закрытий. Редкость хранится в xp_events, визуально — золото + бейдж.
  • Растения и виды: растение рождается только от закрытой задачи (отпечаток работы), вид выбирается детерминированно из пула видов, открытых уровнем на момент рождения (premium-виды — только для rare/epic закрытий), и фиксируется за растением (garden_items.item_key) — открытые позже виды не перерисовывают старые растения. Растение привязано к событию XP (переживает удаление задачи).
  • Семена растений (покупка видов за монеты): в маркете блок «Семена растений» — открытые уровнем виды продаются семенами (цены 30–1200 по «дороговизне» вида; 13 видов, открытие растянуто по уровням 1–13 — прогрессия рассчитана примерно на 300 закрытых задач при среднем ~26 XP за закрытие). Покупка кладёт растение-росток в инвентарь (как декорации); перетаскивание из инвентаря на свободную клетку сажает его. Размещённое семя обратно в инвентарь вернуть нельзя (сад только растёт); редкость — common, задача-родитель отсутствует. Доступность семени — по уровню вида (🔒 «откроется на уровне N»), независимо от premium-флага вида (вопрос о premium-видах в семенах — открыт, см. раздел 8).
  • Рост растений за монеты: растение растёт стадиями росток → куст → цветение, каждая стадия покупается монетами (цена по редкости: common 20/50, rare 40/100, epic 80/200). Автороста по времени нет.
  • Маркет сада: в модалке — расширения карты (первым блоком), декорации, семена растений и каталог видов (справка о редкости). У каждой покупки три состояния: 🔒 «откроется на уровне N» (если уровень не открывает), цена золотом (кнопка активна), цена серым + «не хватает N монет».
    • Декорации — чисто косметика: фонарь, лавочка, поилка, пруд, беседка, флюгер, скворечник, садовый самоцвет, теплица (уникальные), изгородь, клумба и куст (повторяемые). Двигаются как растения. Изгородь — самосоединяющиеся секции: текстура выбирается по маске соседей (N/E/S/W), секция соединяется только с соседними секциями изгороди. Постройки в изометрии (домик, теплица, беседка) — крупная изометрия 2:1 (домик с двором, теплица со стеклянными стенами и каркасом, беседка с площадкой у входа). Дорожки убраны (не прижились — сложность рендера и соединений).
    • Подпись выбранного объекта: клик по любому объекту сада (растение или декорация) показывает у клетки чип с иконкой и названием («Скворечник», «Куст», «Подсолнух»); у растений одновременно открывается поповер роста. Повторный клик по объекту или клик по пустому месту снимает выбор.
    • Инвентарь декораций: покупка кладёт декорацию в инвентарь под картой (garden_items.x/y = NULL) — место покупки всегда видно пользователю. Из инвентаря декорация перетаскивается на карту; перетаскивание размещённой декорации обратно на полосу инвентаря убирает её с карты (растения в инвентарь вернуть нельзя — сад только растёт).
    • Расширения карты (0.37): стартовая сетка 24×12 ячеек — компактный дворик заполняется быстро, расширение ощущается событием. Первое расширение (ур. 3, 100 монет) возвращает размер прежнего старта 32×20, второе (ур. 12, 250 монет) — до 40×28; у существующих садов предметы, оказавшиеся за новой границей, при первом заходе переставляются по спирали к дому.

3.14. Реактивность: серверные события (SSE)

Данные меняются не только из UI: MCP-агенты (3.10) и фоновая автодетализация (3.2) пишут в БД вне HTTP-запроса. Все виды обновляются событиями от сервера — без обновления страницы и переходов между страницами.

  • Эндпоинт: GET /api/events (авторизация штатная, 401 — на вход). Транспорт — SSE (text/event-stream), по одному соединению на вкладку (EventSource, авто-reconnect браузером). Keep-alive: при тишине дольше 20 с сервер шлёт кадр ping, чтобы прокси (Vite dev, nginx) не рвал поток.
  • Модель — грубозернистые события: событие это kind + опциональные данные; фронт реагирует refetch'ем (перечитывает данные целиком), а не патчами. Трейлинг-debounce 300 мс коалесцирует бёрсты (закрытие задачи = 2–3 события). Виды подписываются на свои kind'ы: стек/список — task.changed, task.deleted, detail.changed, project.changed, project.deleted; статистика и «Достижения» — xp.changed, task.changed; сад (код сохранён, страница отключена в 0.58) — garden.changed, xp.changed, task.changed (рефetch подавляется на время drag по сцене); страница задачи — перечитывается при task.changed/detail.changed своего id. garden.changed продолжает публиковаться, но слушателей в UI больше нет — событие безвредно (0.58).
  • Точки публикации (строго после коммита транзакции): HTTP-хендлеры задач, проектов, сада, XP-дейли и настроек; MCP-инструменты и фоновая детализация — broadcast (всем вкладкам). Список kind'ов: task.changed {id}, task.deleted {id}, detail.changed {id}, project.changed {id}, project.deleted {id}, garden.changed {reason}, xp.changed {amount?, celebrate}, settings.changed, ready (первый кадр), ping. С 0.85 — ещё два кадра о работе агента (3.20): task.claimed {id, claimed_by} (задачу взял агент — в интерфейсе появляется пометка «в работе у агента») и task.review {id, title} (агент закрыл задачу — владельцу приходит тост «Нави закрыла задачу: …», а задача встаёт в фильтр «Ждёт приёмки»). Упрощение: закрытие задачи через MCP не шлёт garden.changed — refetch по task.changed покрывает (сад отключён в 0.58, слушателей у события нет).
  • Второй транспорт — Web Push (0.88, 3.21): те же события о работе агента (task.claimed, task.review) уходят системным уведомлением в закрытое приложение, а напоминания приходят только так. Шину это не заменяет: мгновенный refetch открытого интерфейса остаётся за SSE, а при видимой вкладке системное уведомление не показывается — иначе оно дублировало бы тост.
  • Дедуп XP-тостов: действие своей вкладки празднуется по заголовкам ответа (X-Earned-XP, ТЗ 3.13); SSE-эхо и эхо соседней вкладки молчат — окно активности 3 с (localStorage). Соседние вкладки показывают скромный тост «+N XP» без конфетти.
  • Reconnect: при обрыве — backoff 1→2→5→15 с; 401 при проверке сессии — редирект на вход; после переподключения — форс-рефреш всех открытых видов (данные за время разрыва могли измениться).

3.15. PWA

Приложение устанавливается на устройство как приложение (Chrome/Android; вопрос оффлайн-режима данных — открытый вопрос 8.4, пока приложение всегда онлайн).

  • Манифест: standalone, тёмная палитра кита (#16161e — фон и theme-color, чтобы не мигало белым при запуске и не зеленела шапка окна), иконки 192/512 + maskable-512, id/start_url = /, язык ru.
  • Иконки (0.90): растрируются из лого скриптом tools/rasterize_icons.sh — единственный источник, поэтому знак в шапке, иконка вкладки и иконка приложения не разъезжаются. У скругления холста логотипа (rx 10 = 15% стороны) в иконках «any» углы прозрачные: с впечатанным фоном скругление сливается с заливкой холста, и иконка читается квадратом с острыми углами — в том числе в шторке системных уведомлений (3.21), где она и была замечена владельцем. Эти же 192/512 отдаются уведомлению как icon (0.90). Где прозрачность недопустима, фон впечатан: apple-touch-icon.png (180, iOS рисует прозрачность чёрным и обрезает иконку своей маской — отдельный файл, а не 192 из «any») и maskable-512.png (Android обрезает по кругу, холст в safe zone 80%).
  • Service Worker (vite-plugin-pwa, autoUpdate; с 0.88 — свой воркер src/sw.ts, стратегия injectManifest): precache оболочки — собранные ассеты, шрифты/иконки кита (~6 МБ лимит), /api/*, /auth/*, /mcp/* никогда не кэшируются и не получают fallback на index.html. Свой воркер понадобился ради обработчика push (3.21): сгенерированный workbox-воркер его не содержит, а вложить туда код нечем. Поэтому precache, SPA-fallback и CacheFirst вложений теперь описаны в самом sw.ts, а sw.js отдаётся без кэша.
  • Оффлайн-оболочка: при отсутствии сети SW отдаёт оболочку приложения (SPA-fallback) — интерфейс открывается, данные при этом недоступны (запросы к API не кэшируются). Вложения и статические ассеты — CacheFirst.
  • Обновление: новая версия подхватывается автоматически при следующем визите (autoUpdate); SSE-соединение при обновлении рвётся — reconnect покрывает (3.14).
  • Установка с телефона: требует secure context — при доступе по LAN по HTTP (не localhost) браузер SW не зарегистрирует; нужен HTTPS (M7 Docker/nginx).
  • Dev-режим: SW не включён (devOptions выключен) — PWA проверяется на прод-сборке (dist), не на dev-сервере; для отладки push в dev воркер поднимается явным флагом VITE_PWA_DEV=1 (0.88, 3.21).
  • iOS Safari (0.47): типовые проблемы iPhone-версии устранены:
    • поля/селекты/поле тегов на touch-устройствах 16px (media hover:none, pointer:coarse) — иначе iOS зумит страницу при фокусе и зум застревает;
    • 100dvh вместо 100vh у drawer'ов, мобильного меню и модалок (низ уезжал под панель Safari и индикатор «домой»); на старых Safari остаётся 100vh;
    • safe-area (viewport-fit=cover): тосты, контент drawer'а и топбар отступают от индикатора «домой» и статус-бара (в PWA контент идёт под них); apple-mobile-web-app-* меты: standalone для «На экран Домой» (до iOS 16.4 манифест не читается), black-translucent статус-бар, короткий тайтл;
    • тап-подсветка WebKit отключена (у кита есть :active-состояния);
    • кнопка fullscreen сада скрыта, если API не поддерживается (iPhone Safari умеет fullscreen только для video). (Сад отключён в 0.58 — правило историческое, вернётся вместе с садом.)

3.16. Вебхуки системы авторизации (0.35, профиль 0.36)

  • gnexus-auth асинхронно доставляет подписанные события на POST /auth/webhook (контракт — docs/04-events-and-webhooks.md в репозитории gnexus-auth): HMAC-SHA256 заголовков X-GNexus-*, секрет — GAUTH_WEBHOOK_SECRET (создаётся/ротируется в админке SSO, client.webhook/rotate-secret); секрет не задан — приём выключен (503).
  • Обрабатываемые события:
    • user.email_changed, user.profile_updated — синхронизация профиля User (email, name ← profile.display_name, locale, avatar_url) без повторного логина; ключа нет в profile — поле не менялось, не трогаем;
    • user.blocked, user.archived, user.deleted — отзыв всех MCP-токенов пользователя (агент теряет доступ вместе с аккаунтом).
  • Профиль в БД — источник истины: /auth/me отдаёт свежие данные из БД, а не из cookie-сессии, поэтому изменения профиля от webhook видны сразу (без повторного логина).
  • Незнакомые типы событий (включая webhook.test и будущие) подтверждаются 200 без действий — чтобы SSO не гонял retry'и по незнакомому типу; при недействительной подписи/битом payload — 400.
  • Ограничение: cookie-сессии gntodo stateless (подписанная кука), серверно их не отозвать — при блокировке/выходе доступ закрывается на стороне SSO; у gntodo webhook отзывает только MCP-токены.

3.17. Экран входа (0.38, карточка — 0.55)

  • Отдельный маршрут /login вне навигационной оболочки: карточка кита (GnCard, 460px) с логотипом, названием и единственной кнопкой «Войти» — инициация SSO-флоу (/auth/login с return_to). Пока /auth/me не ответил, в карточке только лого и лоадер — кнопка не вспыхивает перед редиректом уже залогиненного; тот же вид, что на экране входа gnexus-creds.
  • Не залогиненный пользователь направляется на экран входа с любого маршрута (/auth/me → 401); после логина возвращается на исходный адрес. Истёкшая сессия в API/SSE-коде тоже ведёт на /login, а не на бэкендовый SSO-флоу.
  • /auth/logout после очистки сессии редиректит на /login (не оставляет пользователя на JSON-ответе).
  • Оффлайн-исключение (ТЗ 3.15): сетевая ошибка /auth/me не считается «не залогинен» — оффлайн-оболочка открывается без редиректа.

3.18. Браузерное расширение быстрого захвата (0.42)

Расширение для Chrome/Firefox (MV3): клик по иконке в трее браузера → попап с двумя вкладками — «Добавить» и «В работе». Быстрый захват входящих (3.1) вне страницы сервиса.

  • Вкладка «Добавить»: многострочное поле (первая строка — название, остальное — описание), Ctrl+Enter или кнопка; опциональный выбор проекта (создание = POST /api/tasks + PATCH project_id, как в веб-форме). Фидбек — анимация-галочка и тост «Задача добавлена (+N XP)»; поле очищается, выбранный проект сохраняется; кнопка «Открыть GNexus Tasks» ведёт на главную.
  • Вкладка «В работе» — только просмотр (0.44: вместо «Активных» со всеми незакрытыми статусами): задачи в работе (in_progress; название, чип проекта, метка просрочки, теги); клик открывает задачу в сервисе, действий в попапе нет.
  • Аутентификация — Bearer MCP-токен (3.10): сессионная кука сервиса не уходит запросам из extension-контекста (SameSite=Lax режет cross-site), поэтому попап ходит на REST /api/* с Authorization: Bearer <MCP-токен>. Токен вставляется один раз из «Настроек → Токены MCP», хранится в chrome.storage.local; отзыв токена отключает расширение (как и агентов, 3.10).
  • Базовый URL настраиваемый: по умолчанию http://localhost:15134; произвольный адрес задаётся в настройках попапа и запрашивает host-разрешение (optional_host_permissions + chrome.permissions.request).
  • Черновик форм сохраняется (0.51): попап — транзиентное окно и закрывается при потере фокуса (клик мимо, даже переключение раскладки Win+Space) — API «не закрывать» у Chromium нет. Поэтому всё заполненное (текст, выбранный проект, вкладка, настройка адреса/токена) пишется в chrome.storage.local и восстанавливается при следующем открытии; после успешной отправки текст очищается (как в веб-форме).
  • Сборка — vite + Vue 3 + gnexus-ui-kit (тот же кит, что веб-интерфейс); язык интерфейса — русский.
  • Страница-инструкция в меню веб-версии (/extension): скачивание ZIP-сборки расширения (раздаётся статикой фронта; в Docker-образе фронта собирается из extension/), шаги загрузки unpacked и подключения токена. Из установленного PWA пункт меню скрыт (display-mode: standalone) — по прямому адресу страница показывает пояснение.

3.19. Визуальный стиль и кастомные компоненты (0.50)

  • Веб-интерфейс использует gnexus-ui-kit 1.0.0: кит задаёт системе визуальный язык (панели/карточки — радиус 6px и бордер 2px --gn-border-color-muted, мелкие контролы/инсеты — 3px; темы Verdant/Ember/Mono и дизайн-токены --gn-* доступны, но не используются пока в gntodo).
  • Полосы прокрутки — с китовыми цветами, но скруглённые (0.76, пересмотрено в 0.79): свою полосу кит рисует только меню-дроверу (.nav-drawer-body: тонкая, бегунок акцентом #7aa2f7 по фону панели), всё остальное берёт глобальное правило кита: 10px, дорожка #16161e, бегунок #414868, кнопок нет. В 0.76 полосы были переведены на «китовый язык» целиком — тонкие, с акцентным бегунком и прозрачной дорожкой; правка отменена: акцентный бегунок читался поверх шапки, а прозрачная дорожка снимала с полосы её служебный вид. Теперь толщина и цвета китовые, добавлено только скругление бегунка. scrollbar-width и scrollbar-color намеренно не задаются: как только они выставлены, Chromium игнорирует ::-webkit-scrollbar-* целиком, и скругление пропадает; у .tabs-list и .nav-drawer-body они стоят у кита — там полосы остаются как были.
  • Подвал карточки задачи — из слота footer кита (0.76): кит рендерит его как <footer class="card-footer"> с одними отступами, а раскладку, размер шрифта и цвет (flex / 13px / #a9b1d6) задаёт только горизонтальному варианту карточки — вертикальному их даём сами. Содержимое карточки кит тянет на height:100%: с подвалом оно вытолкнуло бы его за карточку (в сетке списка у карточки height:100%, а overflow:hidden кита в приложении снят), поэтому карточка задачи раскладывается колонкой, а остаток высоты отдаётся содержимому — подвал прижат к низу и у карточек одного ряда стоит на одной линии.
  • Страница по горизонтали не едет на узком экране (0.78): на 390px карточка задачи уезжала вбок — documentElement.scrollWidth был 533 при вьюпорте 390. Причина в сетке страницы задачи: мобильное правило задавало grid-template-columns: 1fr, а голый 1fr — это minmax(auto, 1fr), и трек не сжимается ниже min-content содержимого (517px в коробке 358px). Теперь трек — minmax(0, 1fr), а колонки-элементы получили min-width: 0. Вторая причина того же симптома — длинные неразрывные токены в описании (URL, инлайновый code вида GAUTH_REDIRECT_URI=https://…): абзац не помещался в узкую карточку, и md-view со overflow-x: auto честно скроллил его по горизонтали (коробка 324px, содержимое 483px). Теперь переносим токен по символам (overflow-wrap: break-word — именно break-word, а не anywhere: intrinsic- размеры не меняются, поэтому таблицы и фенсед-блоки по-прежнему прокручиваются, а не сжимаются; pre с white-space: pre перенос не подхватывает). На широком экране переносить нечего — десктопная раскладка не изменилась.
  • Табы статуса в «Списке задач» оторваны от списка (0.78): отступ снизу под табами был 0.6rem, и табы липли к первой карточке; теперь 1.25rem — тот же шаг, что у сетки страницы задачи. Прокрутка табов по горизонтали на узком экране осталась (0.70).
  • Выпадающие меню на мобильных — шторка снизу (0.77): кит позиционирует .dropdown-menu от левого края триггера (position:absolute; left:0, min-width:220px), поэтому у триггеров у правого края экрана меню уезжало за вьюпорт, и приложение выравнивало координаты инлайном уже после открытия — на телефоне это давало постоянные «съехавшие» меню. До 767px меню вместо выпадашки становится шторкой снизу: панель во всю ширину экрана, прижатая к низу, со скруглением верхних углов 12px, «ручкой»-грабером, пунктами высотой 44px (китовые 34px мелковаты под палец) и прокруткой при длинном списке; появление — выездом снизу (0.22s), а не китовым overlay_reveal. Экран под шторкой затемняется (слой #16161e с прозрачностью 0.72), тап по затемнению закрывает меню и не нажимает то, что под ним. Затемняющий слой лежит вне #app (в index.html) и показывается по :has(.dropdown.is-open): для китового обработчика клик по нему — «вне меню», поэтому закрытие работает без JS, а выравнивание координат на мобильных выключено за ненадобностью. Китовый panel_boot оставляет у шапки страницы transform, а это содержащий блок для position:fixed — на мобильных анимация шапки снята, иначе шторка из шапки раскладывалась бы по коробке шапки, а не по экрану; правило «меню в шапке прижато вправо» тоже оставлено десктопу, на мобильных оно подрезало бы шторку справа. Правка задумана как временная: та же задача оформлена для gnexus-ui-kit (там её место — в самом GnDropdown), при переходе на реализацию кита приложение удаляет свой CSS-блок, слой в index.html и мобильную ветку в dropdownClamp.ts.
  • Дропдаун из шапки страницы задачи на мобильных (0.79): шапка страницы (.app-content .page-header) держала position: relative; z-index: 5 — правило нужно десктопу (китовый panel_boot оставляет transform, и без z-index карточки рисуются поверх выпадашки), но z-index создаёт контекст наложения. Внутри него заперта fixed-шторка: слой затемнения (вне #app, z-index 1090) ложился поверх шторки и перехватывал тапы — меню из шапки на странице задачи открывалось затемнённым, а клик по пункту лишь закрывал его. Теперь position: relative и z-index включены только с 768px; на мобильных шапка контекста не создаёт, шторка попадает в корневой контекст и лежит выше затемнения.
  • Форма создания задачи на мобильных закрывается сама (0.86): после успешного создания задача форма закрывается — и в глобальной форме из «+» в топбаре, и в форме новой задачи проекта, и в форме подзадачи. На десктопе она, наоборот, остаётся открытой: там удобно набивать несколько задач подряд, не открывая форму заново. Правило применяется только к созданию: при правке существующей задачи форма закрывается всегда (после сохранения смотреть в ней больше нечего). Порог мобильного — тот же 767px, что у шторки меню (0.77), и объявлен в одном месте (frontend/src/viewport.ts), чтобы поведения на одной ширине не разошлись. Быстрый захват строкой в стеке (3.1) не форма: он очищает поле и остаётся на месте.
  • Меню у правого края экрана (0.79): выравнивание координат (dropdownClamp.ts) ставило меню инлайновый right: 0, но китовый .dropdown-menu{left: 0} при этом продолжал действовать — оба края заданы, и при min-width: 220px меню оставалось у левого края триггера: у правой колонки карточек (три колонки на 1440px) оно выезжало за вьюпорт на всю свою ширину (right 1593 при 1440). Теперь при развороте влево выставляется и left: auto.
  • «⋯» в шапке страницы на узком экране (0.80): действия шапки у кита переносятся на свою строку и встают у её левого края — на странице задачи меню действий оказывалось под бейджами слева. До 640px строка действий растягивается на всю ширину и прижимается вправо, как «⋯» в карточке. Иконка приведена к общей: ph-dots-three-outline — как в карточках задач, стека и на странице проекта (на странице задачи стоял залитый ph-dots-three).
  • Полоса прокрутки у табов статуса спрятана (0.80): ряд прокручивается по горизонтали (у кита .tabs-list{overflow-x:auto}), но видимая полоса тут только шумит — обрезанный шестой таб и так читается как «дальше есть». Прокрутка сохранена, спрятана только полоса (scrollbar-width: none + ::-webkit-scrollbar для Safari).
  • Переключатель в ряду фильтров сдвинут на 3px вправо (0.80): у кита бегунок — квадрат 18×18 в left: -5px от коробки контрола, поэтому видимый левый край свитча выходил на 3–5px левее соседей по ряду. Сдвиг — компенсация выступа, а не отступ между элементами.
  • Подвал карточки проекта — та же полоса, что у карточки задачи (0.81): у карточки проекта подвал был обычной строкой внутри содержимого — без черты- разделителя, тогда как у карточки задачи с 0.76 это китовый слот footer с чертой во всю ширину. Теперь подвал проекта тоже в слоте footer: содержимое забирает свободную высоту карточки, подвал ложится вниз и отделён чертой во всю ширину, слева свежесть, справа срок — как в карточке задачи. Сама полоса (черта, раскладка, мягкий текст мельче основного, высота пустой строки) стала общей для обеих карточек и живёт в kit-overrides.css: правила были одни и те же в двух местах. Пустой подвал (проект без задач и без срока) держит высоту строки — иначе черта у соседей по ряду встала бы на разной высоте. Скелетон списка повторяет эту полосу, чтобы загрузка не дёргала раскладку.
  • Строка подвала карточки стоит по центру полосы (0.81): у кита .card-footer — 8px сверху против 15px снизу, поэтому «2 недели назад» висело на 3.5px выше середины полосы. Отступы выровнены (12px сверху и снизу), полоса от этого выше прежней на пиксель.
  • Иконка в подвале карточки: китовая поправка снята (0.83): китовое .ph.normalize (position:relative; top:.15em) рассчитано на иконку на общей базовой линии с текстом — там оно опускает глиф из 0.5em над базовой линией (середина ink иконочного шрифта) до середины прописных, 0.36em, и это верно. Но строка подвала — флекс с align-items:center: флекс-элемент коробкой в базовой линии не участвует, а у иконочного шрифта нет нижнего выноса (fontBoundingBoxDescent = 0), поэтому коробка иконки центрируется в строке и её низ ложится на 0.125em ниже базовой линии текста. Поправка кита складывалась с этим центрированием, и глиф уезжал вниз на 0.135em ≈ 1.6px: низ глифа уходил под базовую линию, иконка читалась провалившейся. Замер по пикселям на одной и той же строке («2 недели назад»): центр глифа на 1.3px ниже центра цифры «2». В подвале карточки (свежесть, «без активности», отсчёт срока) поправка снята — top: 0 в kit-overrides.css: расчёт даёт 0.375em против 0.36em, то есть 0.2px, и низ глифа садится на базовую линию, как у буквы. Класс normalize в разметке остаётся — у кита он один на все иконки в тексте, а правка кита заказана отдельной задачей в проекте GNtodo (иконке в флекс-строке поправка не нужна вовсе: предлагается vertical-align: -0.15em вместо position:relative; top, либо отдельный класс без поправки).
  • Иконка в подвале карточки проекта выровнена по тексту (0.81): у свежести и у отсчёта срока иконка шла без китового класса normalize, глиф иконочного шрифта садился выше середины строки и читался подвешенным — как в карточке задачи, где этот класс стоит с 0.76.
  • Карточки проекта и задачи различаются корешком (0.82): карточки двух сущностей выглядели одинаково — та же панель с рамкой 2px, тот же подвал, — и в списке не читалось, что перед тобой папка с задачами, а не сама задача. Теперь у карточки проекта левая грань 6px (китовый --gn-border-width-accent, тот же шаг, что у полосы шапки страницы) и цвета проекта — того же, что метка у имени и заливка прогресса; у проекта без цвета грань берёт акцент кита, как и его прогресс. У карточки задачи рамка осталась ровной 2px, у задачи без изменений. Тот же корешок у карточки проекта в архиве и у заготовок списков (иначе при загрузке карточка дёрнула бы ширину содержимого). Наведение подкрашивает рамку, но не корешок: цвет грани — принадлежность проекту, а не состояние карточки.
  • Пустой подвал карточки проекта говорит «без активности» (0.82): у проекта без задач активности по задачам нет, и подвал оставался пустой полосой — читалось как недоделка. Теперь на месте свежести стоит «без активности» (ключ projects.noActivity).
  • Полоса прокрутки спрятана у всех табов (0.82): правило было только у табов- фильтра «Списка задач», поэтому на странице проекта табы статуса оставались с полосой; теперь оно общее для .tabs-list в kit-overrides.css. Прокрутка сохранена (0.80).
  • Модалка выбора проекта при переносе (0.82): собрана на публичных GnModal + GnSelect кита, с подписью, какая именно задача переезжает. Акцент отдан действию («Переместить»), а «Отмена» осталась контурной: в прочих диалогах приложения действие красится в danger/success, а primary берёт на себя отмена — у переноса своего смыслового варианта нет.
  • Кнопка «назад» над шапкой страницы (0.70): выйти с любой страницы можно было только через меню разделов, возврата в приложении не было. Теперь над шапкой страницы, на каждой странице, стоит стрелка назад — но только если в истории приложения есть куда возвращаться (переход вперёд внутри приложения; при заходе по прямой ссылке в новой вкладке истории нет — кнопка не показывается). Возврат на экран входа смысла не имеет и кнопкой не предлагается. Возврат идёт по истории приложения, а не по иерархии разделов. Размер кнопки — дефолтный кита (34px), не уменьшенный (0.71): кнопка нажимается часто, 28px в ней читались как второстепенная мелочь.
  • Кастомные (сырые) блоки интерфейса оформлены в том же стиле, что и компоненты кита — те же бордеры/радиусы: дерево званий и ачивки на «Достижениях», график и heatmap статистики, блок «свежий токен» настроек, полнотекстовые превью и миниатюры картинок, tags-fieldset формы задачи; при возврате сада — маркет, дерево уровней с unlocks и оверлеи сцены (кнопки зума, подписи объектов, инвентарь).
  • Смысловые состояния сырых блоков подчёркиваются подкрашенным бордером и лёгкой подложкой в цвет состояния (текущий уровень, разблокированные ачивки) — без !important-войн с китом: только каскадное переопределение поверх публичных классов.
  • Иконки в заголовках карточек — в цвете текста заголовка (раньше затемнялись).
  • Логотип и иконки — знак «стек входящих» на холсте (0.54, холст — 0.56, размер знака — 0.57): лого — три строки задач (синий маркер — задача в работе, зелёный — закрытая, остальные строки приглушены) на холсте 64×64 со скруглёнными краями (rx 10, заливка --gn-surface-page) — приём логотипа gnexus-creds: знак читается и как тёмная плитка на светлой панели вкладок, и на тёмных поверхностях кита. Знак занимает ~56% холста (в сетке 64 — 36×35, поля ~14): в 0.56 знак стоял в прежней сетке (62.5%), в 0.57 уменьшен на 10% — scale(.9) вокруг центра знака (32, 32.5) в logo.svg; пропорции строк не меняются. logo.svg (шапка, экран входа, попап расширения) и favicon icon.svg — один и тот же файл (тоже как у creds). PWA: icon-192/512 (any) и maskable-512 — холст 80% кадра (safe zone адаптивных иконок Android). PNG расширения 16/32/48/128 и все PWA-иконки растеризуются из logo.svg скриптом tools/rasterize_icons.sh: фон --gn-surface-page впечатан, потому что прозрачность iOS рисует чёрным (углы холста скруглены, за ними — ничего). До 0.54 знак был в рамке-чипе, в 0.54 рамку сняли с подрезкой viewBox по глифу — знак занимал всю отведённую коробку, но в шапке и на экране входа выглядел «голым»; с 0.56 знак подан на холсте. Попап расширения показывает лого из своего public/ (у упакованного расширения свой корень — ссылка на фронт не работает), поэтому с 0.59 копию обновляет тот же tools/rasterize_icons.sh: одна команда на правку логотипа.
  • Заголовок-ссылка в карточке — не классом card-title (0.59): card-title — имя шапки карточки в ките (header.card-title, у него padding: 15px 15px 0); тот же класс на вложенной ссылке давал вторые 15px, и текст заголовка уезжал вправо от остального содержимого. Ссылка стека носит собственный card-link.
  • Селектор цвета проекта — сырой блок в стиле кита (0.63): в ките компонента выбора цвета нет (проверено: GnChip/GnChipGroup/GnPopover/GnDropdown/ GnRadioGroup/GnRange цвета не принимают), а GnPopover оборачивает содержимое в <p class="popover-text"> — сетку свотчей туда не положить. Панель выбора — свой блок с поверхностью/рамкой/радиусом кита (как .popover-panel), свотчи — отдельные кнопки с aria-pressed/aria-label и тонкой внутренней обводкой (0.72: без неё тёмные ступени сливаются с поверхностью панели); закрытие по клику вне и Escape. Палитра (27 оттенков) строится детерминированно из 8 базовых тонов: шесть — цвета кита (error #f7768e, accent #ff9e64, success #9ece6a, info #bb9af7, secondary #7aa2f7 и циан #2ac3de из палитры темы — своего токена у него нет), два — свои (жёлтый 0.69, коричневый 0.72: таких тонов в ките нет). От базового тона — светлая и тёмная ступени, посчитанные от его светлоты, — «базовый, светлый, тёмный». Ступень, которую зажал предел светлоты (36..88%), в сетку не берётся, поэтому ряды коричневого и сиреневого короче; тёмная ступень берётся с насыщенностью ×0.8: при низкой светлоте полная насыщенность читается на тёмной подложке как неон. Приглушённых вариантов (половинная насыщенность) и отсева «почти одинаковых» клеток по каналам (0.63..0.71, сетка 100 клеток рядами по 12..13) больше нет: в 0.72 ступень отведена от базового не меньше чем на 14% светлоты, а любые две клетки сетки — на ΔE 12.5 по CIELAB, и сетка сократилась почти вчетверо (100 → 27). Нейтральный ряд (0.69) выведен не из базового цвета — у серого оттенка нет, поэтому ступени заданы своей шкалой светлоты (в 0.72 — 5 ступеней через 16%, от #ffffff до #5c5c5c) и рисуются равными каналами, минуя общий зажим светлоты цветных рядов.
  • Ссылка-чип и крупный тайтл карточки (0.65): в ките бейдж — span, ссылки среди бейджей нет, поэтому чип сайта — сырой <a> в геометрии бейджа кита (24px/радиус 3px/13px/600), капс не берём. Тайтл карточки крупнее: размер поднимается правилом :deep(.card-title) — в ките .card .card-title размера шрифта не задаёт (14px приходят наследованием от body), поэтому правило ничего не «перебивает». Метка проекта (кружок + фавикон) — свой компонент ProjectMark; размеры в em от кегля имени: в карточке база — тайтл (16px), в шапке страницы задаётся 18px. Кружок внутри метки крупнее одиночного (ProjectColorDot в бейджах задач и дашборда оставлен прежним) — селектор потомка бьёт правило дочернего компонента без !important. Кольца у фавикона нет (тонкая обводка --gn-border-color-muted снята в 0.65 по просьбе пользователя): край иконки и без неё читается на тёмном фоне. Бейдж проекта — ссылка (0.75): сам бейдж остаётся китовым span, клик и подсветку даёт RouterLink вокруг него (вложенная в <span> ссылка валидна). .badge в scoped-стилях матчится без :deep — это корень дочернего GnBadge, он несёт scope-атрибут родителя.
  • Кликабельная карточка задачи (0.75): обёрткой-ссылкой её не сделать — внутри уже есть ссылка-заголовок, бейдж-ссылка проекта и «⋯»-меню, а вложенные ссылки невалидны. Поэтому @click висит на корне GnCard (кит раскрывает attrs на <article class="card">), а обработчик пропускает клики, у которых event.target.closest('a, button, input, select, textarea, label') что-то нашёл, и клики по выделенному тексту (window.getSelection() непустой — иначе сниппет не скопировать).
  • Цвет-метка — общий компонент-кружок (0.63): кружок рисуется одним компонентом (width/height: .65em, border-radius: 50%) в четырёх местах (карточка списка, шапка проекта, бейдж проекта у задачи, строки проектов дашборда) — иначе стиль дублировался бы по файлам. Нет цвета — нет и кружка. Отступ справа 0.25em (0.73): кружок всегда стоит вплотную к имени проекта — в бейдже задачи он прилипал к нему. Метка ProjectMark этот отступ снимает (там зазор задаёт flex-gap), поэтому интервал перед фавиконом не удваивается.
  • Клетки прогресса — поверх полосы кита (0.64): сегментированной полосы в ките нет (GnProgress — одна заливка, GnProgressStages — сетка этапов), поэтому карточка проекта рисует клетки насечками поверх трека (::after трека с repeating-linear-gradient, шаг 100%/M), а сама полоса остаётся GnProgress — с ролью progressbar, aria-valuenow/max и анимацией ширины. Переменные оформления (--card-color, --task-cells, --done-cells) вешаются на корень карточки, а не на полосу: кит в GnProgress ставит свой style после ...attrs, и переданный извне style теряется.

3.20. Мандат ИИ-агента: доступность, взятие, приёмка, журнал (0.85)

Агент ходит персональным токеном владельца (3.10), поэтому до 0.85 его действия были неотличимы от пользовательских, а взять задачу в работу он мог только «вслепую». Раздел вводит три вещи: мандат (какие задачи агенту можно), взятие (чтобы двое не делали одну работу) и журнал (кто, что и когда — навсегда в БД).

1. Доступность задачи. У задачи есть флаг ai_eligible — «доступно для ИИ-агента».

  • Ставит владелец — чекбокс в форме задачи и правкой существующей; по умолчанию нет.
  • Агент может поставить флаг только при создании задачи (своей): иначе правило «без флага не трогать» обходилось бы вызовом «пометил — закрыл».
  • Задачи, созданные агентом, помечаются в самой задаче (created_by_kind = agent, created_by_name — label токена): в интерфейсе у них бейдж, а список по этому признаку фильтруется (иначе признак пришлось бы вычислять join'ом).
  • Регулярная задача наследует флаг от предыдущего экземпляра (3.5).
  • Оценки «по силам ли задача агенту» система не делает: это решение владельца, и только его.

2. Взятие в работу (аренда). Задачу берут не «в уме», а явно — claim_task.

  • Взять можно только задачу с флагом, в статусе to_do и со свободной арендой; аренда выдаётся на 2 часа, повторный вызов того же агента её продлевает (heartbeat).
  • Живая чужая аренда блокирует не только взятие, но и изменение задачи агентом: двое не сделают одну работу и не закроют задачу друг за другом. Владельцу аренда не мешает — его действие её снимает.
  • Протухшая аренда (агент пропал) считается свободной сама, по времени — без фоновых задач и крона. Выход задачи из to_do/in_progress, закрытие и удаление снимают аренду.
  • Агент, которому задача не по зубам, возвращает её: release_task с причиной.

3. Приёмка. Работа агента не становится окончательной сама.

  • Закрывая задачу, агент обязан приложить комментарий — что сделано; закрытие без комментария инструмент отклоняет. Владельцу комментарий необязателен.
  • Закрытая агентом задача помечается «ждёт приёмки» (accept_state = pending) и попадает в одноимённый фильтр списка задач, а на странице задачи появляются «Принять» и «Вернуть в работу».
  • Владелец либо принимает работу (accepted), либо возвращает её (rejected с причиной): задача снова в to_do, аренда снята. XP и монеты не отзываются (3.13) — принцип «только позитив»; второй награды за ту же задачу не будет (идемпотентность по задаче, 8.7), как и второго экземпляра регулярной.
  • Приёмка — отдельная плоскость, а не статус (3.4): набор статусов не меняется, done остаётся «сделано» для статистики, теплокарты и спавна регулярной. Владелец, закрывший задачу сам, приёмкой не занимается — его закрытие снимает пометку.
  • Принимать и возвращать может только владелец; агенту инструмент отказывает.

4. Журнал (task_events). Каждое действие по задаче оставляет запись: кто (actor_kind — user | agent; actor_name — имя владельца или label токена), что (kind: создание, смена статуса, смена доступности, взятие, отпуск, закрытие, приёмка, возврат, удаление) и когда; канал — ui | api | mcp. Комментарий закрытия и причина возврата живут в записи.

  • Страница «Журнал» (/journal, отдельный пункт меню) — таблица записей с фильтрами по типу события, актору и проекту и с пагинацией. Это первый пагинированный список на бэкенде: GET /api/task-events отвечает конвертом { items, total, page, per_page } (потолок per_page — 100). Вкладки-фильтры «Ждёт приёмки» и «Создано» (мной / агентом) живут в списке задач (3.6).
  • Записи переживают задачу: task_id при удалении обнуляется, но заголовок сохранён снапшотом — журнал читается как история, а не как список живых задач.
  • О работе агента узнают не только из журнала: взятие и закрытие публикуются по SSE (3.14) — пометка в интерфейсе и тост владельцу.

5. Передача задачи агенту вручную (0.87). Агент находит задачи сам, но владельцу часто нужно отдать конкретную: на странице задачи в меню «⋯» есть пункт «Промпт для ИИ» (подпись короткая намеренно — в китовом меню 220px длинная рвётся на две строки, как в 0.83; само окно называется «Промпт для ИИ-агента»). Он собирает текст задачи — id, заголовок, статус, тип, приоритет, оценку и факт, деньги, срок, проект, теги, родителя, пометку доступности, живую аренду, описание, черновое предложение детализации, подзадачи и ссылку на страницу — и показывает его в окне, где текст можно поправить; копирует его публичная кнопка кита (GnCopyButton), так что в буфер попадает ровно то, что в окне.

  • Содержимое — только факты о задаче: что с ней делать, владелец пишет сам в своём чате с агентом. Кнопка не диктует агенту ни протокол работы, ни объём.
  • Текст собирается по открытию окна, а не один раз при загрузке страницы: пока окно открыто, задача успевает измениться (SSE), а в буфере должен оказаться её текущий вид.
  • Подписи полей — из локали, значения — теми же форматтерами, что в интерфейсе (статус, приоритет, время, деньги, дедлайн), поэтому промпт и страница не расходятся.

Актор действия. У изменяющих MCP-тулов есть флаг is_user: по умолчанию действие считается агентским, is_user=true — «действую от имени владельца» (на REST тот же смысл у заголовка X-Actor: user, им объявляет себя браузерное расширение). В журнале у такого действия актор — владелец, но канал остаётся mcp/api: видно, что действие заявлено от его имени и пришло от агента.

Это не охрана. ai_eligible — не контроль доступа, а рамка делегирования. Держатель валидного токена и так действует как владелец (токен — его принципал), и объявить is_user=true он вправе в любой момент. Ценность мандата — в видимости и в защите от случайности: агент не тронет задачу, которую владелец ему не поручал, а всё, что он сделал, видно в журнале. Строить на ai_eligible разграничение прав нельзя.

3.21. Системные уведомления (Web Push) (0.88–0.89)

Работа ИИ-агента идёт, пока приложение закрыто, и до 0.88 владелец узнавал о ней только из открытой вкладки (3.14, 3.20). Системные уведомления закрывают этот пробел: те же события доставляются в закрытое приложение, туда же приходят напоминания, которых до 0.88 не существовало вовсе.

1. Место в шине. Web Push — второй транспорт событий 3.14, а не её замена. Доставку выполняет push-сервис браузера, поэтому природа у неё другая: задержка и батчинг, шифрованный payload до ~4 КБ, адресат — service worker, а не страница. Мгновенный refetch открытого интерфейса, дедуп эха и форс-рефреш после reconnect остаются на SSE: подменять их push'ем нельзя.

2. Что приходит уведомлением. Работа агента: «взял задачу» (task.claimed) и «закрыл — ждёт приёмки» (task.review). Это ровно те события, о которых владелец иначе не узнает, пока не откроет приложение. Напоминания (0.89) — второй источник: строгий дедлайн, ритм регулярной задачи и утренняя сводка; их правила — в пункте 9.

3. Подавление при видимой вкладке. Перед показом воркер смотрит на окна приложения того же источника и, если есть видимое, уведомление не показывает: там работает внутренний тост (GamifyBridge). Проверяется именно видимость окна, а не факт открытого SSE-соединения: вкладка в фоне соединение держит, а уведомление как раз нужно.

4. Разрешение. Без разрешения браузера подписка невозможна. При входе (после успешной проверки сессии) приложение запрашивает разрешение по умолчанию: если его ещё не спрашивали и push на сервере настроен — запрос и, при согласии, подписка. Оговорка: Safari и Firefox требуют, чтобы запрос шёл от жеста пользователя, а возврат со SSO — это загрузка страницы без клика, поэтому запрос может быть молча проигнорирован (Chrome в таких случаях отвечает «тихим» отказом). Надёжный путь — переключатель в настройках: он всегда идёт от клика. После явного отказа приложение не переспрашивает.

5. Настройки. На странице «Настройки» — блок «Уведомления»: переключатель (про это устройство), пояснение, что именно придёт, текущее состояние и число подписанных устройств. Отключение — это отписка устройства и удаление его подписки на сервере.

6. Ключи VAPID. Пара ключей берётся из окружения (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT), печатает её uv run python -m app.vapid. Ключей нет — уведомления тихо выключены (в блоке настроек видно, что функция не настроена), а приложение и тесты работают: в отличие от SESSION_SECRET это не дыра в безопасности, а опциональная функция, и падать на старте из-за неё нельзя.

7. Тексты собирает сервер. Уведомление формируется до открытия приложения, поэтому его строки лежат в серверном словаре (ru/en/uk) и выбираются по языку пользователя: настройка языка → SSO-локаль → русский. Строки дублируют фронтовые локали осознанно — service worker локалей не знает.

8. Ограничения среды. Push требует secure context: по HTTP (не localhost) не работает ни service worker, ни подписка — нужен HTTPS, который появится на M7. На iOS push доступен с 16.4 и только у приложения, добавленного на «Экран Домой». Сквозной круг доставки через push-сервис проверяется на живом устройстве: в headless-браузере подписка может не найти push-сервис.

Уведомления в журнал task_events не пишутся: он про действия с задачами, а не про доставку.

9. Напоминания (0.89). Приложение молчит, пока его не откроют, поэтому о сроке и о регулярном деле напоминает планировщик — не только открытая вкладка.

Что напоминаем. Строгий дедлайн разовой задачи — накануне срока, в день срока и один раз о просрочке. Ритм регулярной задачи — «пора: зарядка» в день, который правило повторения (3.5) и так назначило; день считается тем же recurrence.next_date, что создаёт следующий экземпляр, отдельного «календаря напоминаний» нет, иначе правила разъехались бы. Следствием этого задача, созданная сегодня, впервые напомнит о себе в свой следующий день: сегодняшний экземпляр у владельца и так на руках. Утренняя сводка — «просрочено / срок сегодня / ждут приёмки», отправляется в окне 09:00–14:00 по локальному времени и молчит (не отправляется и не пишется в журнал), если сказать нечего.

У регулярной повод один — ритм (0.91). С тех пор как экземпляр получает дату следующего раза (3.5), дедлайновые правила его не касаются: иначе в день срока об одном деле приходило бы два уведомления — «Срок сегодня» и «Пора». По той же причине регулярные не попадают в счётчики сводки «просрочено» и «срок сегодня» (в «ждут приёмки» попадают: закрытая агентом работа ждёт решения владельца независимо от типа задачи).

Что не напоминаем. Нестрогий срок («в течение недели/месяца/года») не напоминает вовсе: в модели это период без дня отсчёта, разворачивать его в дату мы не стали. Задачи в статусах done, cancelled и deferred — тоже молчание: «сейчас не в приоритете» это осознанное решение владельца, и напоминание о сроке противоречило бы ему.

Дневная гранулярность. Итерация планировщика идёт каждые 15 минут, но поводы напоминаний дневные: напоминание не привязано к часу, и «просрочено» звучит один раз, а не каждые сутки.

Дедуп. Отправленное записывается в push_deliveries (unique(user_id, kind, ref_key)) — до отправки. Поэтому повторный тик и перезапуск контейнера дублей не дают, а перенос срока рождает новый повод: в ref_key входят id задачи и дата — у дедлайна дата срока, у ритма день, о котором напоминаем. Id обязателен (0.91): без него ключ был общим на пользователя, и, если на сегодня выпадали две регулярные, второе «Пора» молча съедала уникальность — за день приходило одно напоминание, сколько бы задач ни совпало. Обратный размен (падение между записью и отправкой) даёт пропуск, а не дубль: для напоминания это правильная сторона ошибки.

Тихие часы. Настройка quiet_hours («HH:MM-HH:MM», app_settings) — окно, в которое напоминания не приходят; в интерфейсе это переключатель с окном 22:00–09:00. Окно может пересекать полночь. Попавшее в окно напоминание не отправляется и не записывается: условие дневное, поэтому следующий тик после окна отправит его сам. Работа агента тихими часами не глушится — она ждёт решения владельца.

Где живёт планировщик. Задача в lifespan приложения: итерация уходит в отдельный поток (внутри синхронные SQLAlchemy и HTTP-запросы к push-сервису), а цикл отменяется вместе с приложением. Сбой одной итерации логируется, но не прекращает напоминания до перезапуска. Пояс напоминаний — из окружения (REMINDER_TIMEZONE), один на инстанс (открытый вопрос 8.13).

4. Нефункциональные требования

Требование Значение
Архитектура Клиент-серверное; SPA + REST API + MCP
Фронтенд SPA на Vue 3 с gnexus-ui-kit (npm: gnexus-ui-kit@^0.4.0, peer: Vue ^3.4)
Бэкенд Python (FastAPI — предположительно), клиент SSO — gnexus-gauth
СУБД PostgreSQL (рекомендация для VPS)
Авторизация SSO через auth.gnexus.space, библиотека gnexus-gauth (https://git.gnexus.space/git/root/gnexus-auth-client-py.git)
Хостинг VPS, HTTPS
AI Ollama на внешнем сервере (адрес и модель — конфигом, OLLAMA_BASE_URL/OLLAMA_MODEL); контейнера ollama в compose нет
Упаковка В итоге всё пакуется в Docker (docker-compose: API, PostgreSQL, фронт; TLS — внешний reverse-proxy)
Мобильность На старте — только PWA; Android-приложение — вне скоупа стартовой версии (вернуться к нему позже)
Ширина контента До 1440px на десктопе (0.68; было 1200px): контейнер контента тянется по ширине окна и упирается в 1440px, дальше центрируется с полями; на этой ширине списки задач и проектов раскладываются по три в ряд (3.6, 3.8)
Приватность Все данные и LLM-вызовы — на собственном сервере
Реактивность SSE (/api/events): грубозернистые события + refetch, см. 3.14
PWA манифест + SW (precache оболочки, autoUpdate), оффлайн-оболочка без кэша API, см. 3.15

Внешние зависимости проекта (не форкать, обновлять через пакетный менеджер):

  • gnexus-ui-kit — часто обновляется, использовать только публичный API кита;
  • gnexus-gauth — клиентская библиотека SSO.

Исходящие запросы за иконкой сайта (0.63, 3.11) — единственный случай, когда бэкенд ходит в интернет по адресу, заданному пользователем (в т.ч. агентом через MCP), поэтому запрос ограничен барьером SSRF:

  • только схема http/https и непустой хост, длина адреса ≤ 2048;
  • только публичные адреса: имя резолвится, и каждый полученный IP обязан быть глобальным (ipaddress.is_global) — private/loopback/link-local/reserved/CGNAT (включая метаданные облака 169.254.169.254) отклоняются;
  • редиректы проходятся вручную (≤ 3), хост проверяется на каждом хопе;
  • лимиты: connect 3 c / read 4 c / всего 6 c, тело ≤ 256 КБ, content-type обязан быть изображением, а расширение определяется по magic-bytes;
  • ответ отдаётся с X-Content-Type-Options: nosniff и Cache-Control: private, без возможности исполниться как документ.

Остаточный риск: между проверкой хоста и соединением HTTP-клиент резолвит имя повторно (DNS-rebinding, TOCTOU); полное закрытие требует соединения по IP с подменой Host/SNI и ломает проверку TLS-сертификата. Для личного приложения риск принят — основной вектор (публичный URL → метаданные облака) закрыт.

5. Модель данных (черновик)

Мультиюзерность (с 0.34): на задачах, проектах, тегах, документах, XP/монетах, саде и настройках есть user_id (nullable — NULL забирается первым вошедшим, см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. Модель сада (0.58) сохранена без изменений — раздел отключён, данные ждут возврата; XP/монеты и rarity продолжают начисляться, как раньше.

User          — пользователь SSO: id (= user_id от SSO), email, name (display_name),
                avatar_url, locale, created_at
McpToken      — персональный MCP-токен (3.10): user_id, token_hash (sha256,
                уникален), label, created_at; plaintext показывается один раз

Task
  id, title
  description      markdown — живёт в Document (owner_type = task)
  task_type         one_time | recurring          — тип задачи: разовая / регулярная
  status            to_do | in_progress | done | cancelled | deferred
  detail_state      raw | approved                — плоскость детализации (стека)
  parent_task_id    nullable  — дерево подзадач
  project_id        nullable
  tag_ids           []
  priority          nullable  (заполняется автодетализацией / вручную)
  deadline          nullable  — дедлайн:
                        strict  { date }                  — «до конкретной даты»
                        soft    { period: week|month|year } — «в течение периода»
                        (0.91) у регулярной задачи строгая дата — дата следующего
                        экземпляра (3.5), а не срок работы; оба поля разом
                        запрещены (3.4), поэтому экземпляр с нестрогим периодом
                        даты не получает
  recurrence        nullable  — правило повторения (для task_type = recurring)
  time_estimate     nullable  — прогноз времени (система)
  budget            nullable  — ручной бюджет (деньги и/или время), опционален
  estimated_cost    nullable  — оценка затрат по задаче (сравнивается с бюджетом)
  actual_time       nullable  — фактические затраты (для обучения прогноза)
  created_at, approved_at, done_at
  ai_eligible       bool — задача доступна ИИ-агенту (3.20, 0.85); по умолчанию false
  created_by_kind   user | agent — кто создал задачу; created_by_name — label токена
                    агента (0.85, 3.20): по этим полям список фильтруется в UI
  done_by_kind      user | agent, nullable — кто закрыл задачу последним
                    (NULL — до 0.85, неизвестно)
  accept_state      pending | accepted | rejected, nullable — плоскость приёмки
                    агентской работы (3.20); NULL — приёмка не применяется
  claim             nullable — аренда агента (3.20): claimed_by_token_id (→ mcp_tokens,
                    ON DELETE SET NULL), claimed_by_name (label, только для показа),
                    claimed_at, claim_expires_at (время протухания)

Project
  id, name
  relevance_status  active | paused               — статус актуальности
  is_archived       bool — архив (3.8.1): проект и его задачи скрыты
                    из рабочих видов, восстановимы из «Архива»
  pinned            bool — булавка (3.8, 0.66): закреплённые проекты
                    держатся секцией «Закреплённые» вверху списка
  priority
  note              markdown — живёт в Document (owner_type = project):
                    ссылки, контекст, картинки; ссылка на репозиторий —
                    в отдельном поле repository_url (0.67)
  color             nullable — цвет-метка проекта, HEX #rrggbb (3.8, 0.63);
                    палитра выбора — на фронте, бэкенд валидирует только формат
  site_url          nullable — сайт проекта, http(s)://... (3.8, 0.63);
                    иконка сайта тянется бэкенд-прокси с кэшем (3.11)
  repository_url    nullable — репозиторий проекта, http(s)://... (3.8, 0.67);
                    README оттуда тянется бэкенд-прокси с кэшем (3.11)
                    и показывается вторым описанием рядом с заметкой

Tag           — справочник тегов
Document      — markdown-текст с полиморфной привязкой к владельцу:
                owner_type (task | project), owner_id, body (у задачи —
                описание, у проекта — заметка); document всегда один на владельца
Attachment    — файлы документов (изображения), document_id, mime.
                URL скачивания файла неизменен: /api/attachments/{id}/file —
                он зашит в сохранённый markdown-текст

XpEvent       — начисление XP за закрытую задачу (идемпотентно по task_id):
                amount, rarity (common | rare | epic), via_options

CoinEvent     — журнал монет (заработок и траты; баланс = Σ amount):
                task_id (nullable), level (nullable — бонус уровня),
                source (task | level | daily | shop), amount (+/−), item_key
                (ключ покупки для трат), created_at. В 0.58 источник баланса
                для UI — `GET /api/xp` (поле coins); траты (source=shop)
                возможны только из сада

GardenItem    — элемент сцены сада: kind (plant | decoration),
                ref_id (→ xp_events для растений; уникально в паре с kind),
                item_key (вид растения / ключ декорации), x, y (ячейки сетки),
                stage (0 росток, 1 куст, 2 цветение). С 0.58 новые записи
                не создаются: ленивая ensure_garden вызывается только из
                `GET /api/garden`, который UI не зовёт

TaskEvent     — журнал действий по задачам (3.20, 0.85): кто, что и когда.
                task_id (nullable, ON DELETE SET NULL — журнал переживает задачу),
                task_title (снапшот заголовка, чтобы запись читалась после удаления),
                project_id (nullable), kind (created | eligible_changed | claimed |
                released | completed | accepted | rejected | status_changed |
                deleted), actor_kind (user | agent), actor_name (имя владельца или
                label токена), via (ui | api | mcp), comment (nullable — что сделано
                или причина возврата; у смены статуса и доступности — машинный
                переход кодами: `to_do→in_progress`, `on`/`off`, их подписывает
                интерфейс), created_at. Единственный пагинированный
                список на бэкенде: `GET /api/task-events` отвечает конвертом
                { items, total, page, per_page }, потолок per_page — 100

AppSetting    — per-user настройки (валюта, язык): PK (user_id, key), value
AppSettingGlobal — таблица-наследие однопользовательской версии
                (бывшая app_settings); источник claim при первом логине,
                позже удаляется

PushSubscription — подписка браузера на системные уведомления (3.21, 0.88):
                user_id (NOT NULL, ON DELETE CASCADE), endpoint (уникален
                глобально: тот же браузерный профиль может перейти к другому
                пользователю, тогда подписка перепривязывается), p256dh и auth
                (ключи шифрования устройства), user_agent, created_at,
                last_success_at, failure_count — мёртвые подписки (404/410 и
                постоянные ошибки) удаляются лениво, при отправке

PushDelivery  — журнал отправленных напоминаний (3.21, 0.89): user_id (NOT NULL,
                ON DELETE CASCADE), kind (вид напоминания), ref_key (повод:
                задача и дата срока, либо день ритма, либо дата сводки),
                created_at, unique(user_id, kind, ref_key). Защита от дублей:
                запись идёт до отправки, поэтому повторный тик и перезапуск
                контейнера не рассылают одно и то же. Не чистится — ретенция
                в открытом вопросе 8.14

История завершённых задач (фактическое время) — источник для прогнозирования длительности.

6. Архитектура (черновик)

┌─────────────┐     ┌────────────────────── VPS (docker-compose) ─────────────┐
│  Web SPA    │────▶│  API (FastAPI) ──▶ PostgreSQL                           │
│  Vue 3 +    │     │   ▲    │                                                │
│  gnexus-    │     │   │    ├──▶ Сервис автодетализации ──▶ Ollama (модель    │
│  ui-kit     │     │   │    │      — LLM, модель из конфига)                  │
└─────────────┘     │   │    ├──▶ Хранилище вложений (файлы)                   │
      ▲             │   │    └──▶ MCP-сервер (для ИИ-агентов)                  │
      └─ SSE ───────┤ шина событий (realtime)                                  │
                    └─────────────────────────────────────────────────────────┘
                                     ▲ SSO auth.gnexus.space (клиент gnexus-gauth)
  • API и MCP-сервер — одно Python-приложение (общая бизнес-логика).
  • Вложения — файлы на диске VPS + метаданные в БД.
  • Изменения данных публикуются в шину событий и уходят в SPA по SSE (3.14).

7. Этапы разработки (предложение)

Этап Содержание
M0 — каркас Репозиторий, скелет backend + frontend, SSO-интеграция (gnexus-gauth), деплой на VPS
M1 — MVP задач CRUD задач, быстрый ввод, стек входящих, теги, проекты (базово), ручная детализация
M2 — детализация Интеграция Ollama + модель из конфига, автопредзаполнение метаданных, подтверждение/редактирование, Markdown-редактор, вложения из буфера
M3 — структура Подзадачи (parent_task_id), классический список с фильтрами, заметки проектов
M4 — умность Прогнозирование времени, бюджет, выдача задачи
M5 — MCP MCP-сервер, инструменты для агентов
M5+ — мандат агента Доступность задачи для ИИ-агента, взятие в работу (аренда), приёмка работы, журнал task_events (0.85, 3.20)
M6 — мобильность PWA (Android-приложение — позже, вне стартового скоупа)
M6+ — уведомления Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21). Регулярные задачи: дата следующего экземпляра у клона, метка в карточке, фильтр «Регулярные» (0.91, 3.5)
M7 — упаковка Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель)

Порядок M4/M5 может меняться — MCP можно поднять раньше ради ИИ-сценария.

8. Открытые вопросы

  1. Правила повторения — решено 2026-09-19: три правила — интервал N дней, дни недели (ISO), день месяца (без дня — последний); якорь — фиксированный календарь от даты выполнения; новый экземпляр рождается при завершении предыдущего.
  2. Прогнозирование времени — решено 2026-09-19: оценка длительности приходит из LLM-детализации (или задаётся вручную); точность не требуется — оценка нужна, чтобы отсекать заведомо большие задачи при малом доступном времени.
  3. Оценка затрат к бюджету — решено 2026-09-19: оба поля (бюджет и оценка затрат) — ручные; валюта — глобальная настройка, выбирается один раз и применяется всюду (UAH, USD, EUR, GBP, PLN).
  4. Оффлайн-режим в PWA: нужен ли, или всегда онлайн?
  5. Модель для Ollama — решено 2026-09-21: по умолчанию qwen3.5:2b-q4_K_M (проверена на M2); меняется конфигом (OLLAMA_MODEL) без правок кода. Ollama — внешний сервер, адрес — OLLAMA_BASE_URL.
  6. Практическое различие cancelled / deferred: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся.
  7. Анти-дюп награды за статус — решено 2026-09-21: закрытие (HTTP и MCP) идёт через общий путь; XP — один раз на задачу (grant_task_xp), спавн регулярной — один раз на цепочку (метка spawned_at), выход из done сбрасывает done_at. Повторные закрытия и обновления закрытой не дублируют награды и экземпляры — покрыто тестами.
  8. Пиксель-арт сцена сада — решено 2026-09-20: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. Отложено вместе с садом (0.58) — код сохранён, вернётся при доработке сада.
  9. Premium-виды в семенах — отложено вместе с садом (0.58): premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить при возврате сада.
  10. Самосоединяющийся забор — отложено вместе с садом (0.58): секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить при возврате сада.
  11. Возврат сада (0.58): когда основной функционал будет в порядке, вернуть сад к доработке — выдача растений включится сама, если UI снова начнёт звать GET /api/garden; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных.
  12. Ретенция журнала (0.85): task_events пишет всё жизненное, включая смену статуса, и растёт вместе с работой. Пока пагинации и потолка per_page хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче.
  13. Часовой пояс напоминаний (0.89): REMINDER_TIMEZONE — один на инстанс, поэтому «сегодня» и окно сводки считаются по поясу сервера, а не по поясу владельца. Для личного развёртывания этого достаточно (сервер и владелец в одном поясе), но при переезде или втором пользователе в другом поясе напоминания придут не в его утро. Решать вместе с вопросом мультипользовательности: пояс в профиле или вычислять по последней активности устройства.
  14. Ретенция push_deliveries (0.89): журнал отправленного растёт по одной строке на повод (задача + срок, день ритма, день сводки) и не чистится — старые записи нужны только до конца своего дня. Строки дешёвые, но вечные: решить, удалять ли их по возрасту (например, старше месяца) регулярной уборкой или оставить как историю отправленного.