Newer
Older
gnexus-tasks / docs / TZ.md

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

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

Версия ТЗ 0.88
Дата 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); пустые поля тоже показываются («— не указано») и редактируются кликом. То же — на странице проекта (название, заметка, актуальность, приоритет). Полный режим правки (вся форма целиком) сохраняется наряду с инлайном.
  • 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. Регулярные задачи

  • Отдельный тип задачи — регулярная (повторяющаяся) в противовес разовой.
  • Повторение создаёт новую задачу по правилу после выполнения/истечения предыдущей.
  • Точный набор правил повторения (ежедневно/еженедельно/интервалы) — уточняется.
  • Новый экземпляр наследует доступность для ИИ-агента (3.20) от предыдущего: цепочка регулярной не теряет мандат.

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.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.
  • 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.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). Это ровно те события, о которых владелец иначе не узнает, пока не откроет приложение.

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 не пишутся: он про действия с задачами, а не про доставку.

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 } — «в течение периода»
  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 и
                постоянные ошибки) удаляются лениво, при отправке

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

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)
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 хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче.