diff --git a/docs/TZ.md b/docs/TZ.md
index 03a4088..b3c1a31 100644
--- a/docs/TZ.md
+++ b/docs/TZ.md
@@ -4,7 +4,7 @@
| | |
|---|---|
-| Версия ТЗ | 0.82 |
+| Версия ТЗ | 0.83 |
| Дата | 2026-10-10 |
| Статус | На обсуждении |
@@ -231,7 +231,8 @@
(слот `footer` китового `GnCard`), а не в ряду бейджей: подвал отделён чертой
1px и прижат к низу карточки, поэтому в сетке списка он у всех карточек на одной
линии. Иконка рядом с текстом идёт с китовым классом `normalize` — без него глиф
- иконочного шрифта садится на 1.7px выше середины строки.
+ иконочного шрифта садится на 1.7px выше середины строки (в подвале карточки сама
+ поправка снята с 0.83 — см. 3.19).
- **Проект задачи — из меню карточки** (0.82): в «⋯»-меню карточки задачи рядом стоят
«Перейти к проекту» и «Переместить в проект» — это один и тот же вопрос «в каком
проекте задача». «Перейти» показывается у задачи с проектом и **не показывается на
@@ -243,7 +244,9 @@
бэкенд проверяет, что проект принадлежит пользователю, и рассылает `task.changed`.
**XP и монеты за перенос не начисляются** — они висят только на переходе в
«завершено». Успех подтверждается тостом, список перечитывается сразу (`moved`),
- поэтому задача тут же уходит со страницы прежнего проекта.
+ поэтому задача тут же уходит со страницы прежнего проекта. В 0.83 пункт сокращён до
+ **«В другой проект»**: надпись «Переместить в проект» в китовом меню шириной 220px
+ рвалась на две строки.
### 3.7. Главная страница (дашборд + стек)
@@ -438,6 +441,32 @@
работает на значениях по умолчанию (по статусу — «К выполнению»). Сохранённый
прежними версиями выбор тега по `id` не восстанавливается осознанно: имя тега по
`id` известно только после загрузки справочника.
+- **Теплокарта активности проекта** (0.83): на странице проекта, в сайдбаре рядом с
+ «Статистикой», — та же сетка «как в гитхабе», что на странице статистики, но по
+ задачам одного проекта: столбец — неделя (с понедельника), строка — день недели,
+ заливка — по числу **закрытых** за день задач (шкала общая со статистикой:
+ 1 / 2–3 / 4–6 / 7+). Дни без закрытий — пустые клетки, будущие дни — почти
+ погашенные. В подсказке клетки — дата и оба числа: «закрыто N, создано M»
+ (заливка про закрытия — это и есть работа по проекту, появления видны в
+ подсказке). Периодов два, переключатель — табами в карточке: **«За месяц»
+ (по умолчанию)** — пять недель, 35 дней, и «За полгода» — 26 недель, 182 дня.
+ Бэкенд для неё не нужен: страница проекта и так держит все свои задачи, включая
+ завершённые (3.6), поэтому история считается на клиенте — закрытия по `done_at`,
+ появления по `created_at`. В содержимое сайдбара сетка влезает без горизонтальной
+ прокрутки: в полугодовом режиме ячейки 9px с зазором 2px (26 недель — 284px),
+ в месячном — 18px (пятью неделями мелкая сетка терялась бы в широкой карточке);
+ легенда шкалы встаёт рядом с сеткой, а когда сетка занимает всю ширину —
+ переносится под неё.
+- **Теги проекта — облаком, клик по тегу фильтрует** (0.83): список тегов в карточке
+ «Теги» заменён облаком — теги идут подряд, а размер шрифта показывает, сколько
+ задач с тегом (0.85–1.2em по ранжиру, точное число — в подсказке). Клик по тегу
+ ставит его в фильтр (тот же поиск по подстроке имени, 0.70), клик по активному тегу
+ фильтр снимает; активный тег подсвечен акцентом. До 960px сайдбар стоит ниже списка
+ задач, поэтому после клика список подтягивается к верху экрана — иначе результат
+ фильтра оказывался бы за кадром.
+- **Задачи проекта — по две в ряд** (0.83): карточки задач на странице проекта
+ раскладываются сеткой по две в ряд (до 700px — одна колонка, как и раньше).
+ Карточки одного ряда выравниваются по высоте, подвал прижат к низу.
#### 3.8.1. Архив
@@ -997,6 +1026,24 @@
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`, глиф иконочного шрифта
садился выше середины строки и читался подвешенным — как в карточке задачи, где
diff --git a/frontend/src/components/ProjectHeatmap.vue b/frontend/src/components/ProjectHeatmap.vue
new file mode 100644
index 0000000..c76dcae
--- /dev/null
+++ b/frontend/src/components/ProjectHeatmap.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+ {{ t('projectPage.activity') }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue
index 559c42d..20fb780 100644
--- a/frontend/src/components/TaskCard.vue
+++ b/frontend/src/components/TaskCard.vue
@@ -70,7 +70,9 @@
]
: []),
{
- label: t('task.moveToProject'),
+ // «В другой проект» (0.83) вместо «Переместить в проект»: в китовом меню
+ // (220px) длинный пункт рвался на две строки
+ label: t('task.moveToOther'),
icon: 'ph-arrows-left-right',
onSelect: () => {
moveOpen.value = true
diff --git a/frontend/src/kit-overrides.css b/frontend/src/kit-overrides.css
index 6fb1b3a..dfbb9fa 100644
--- a/frontend/src/kit-overrides.css
+++ b/frontend/src/kit-overrides.css
@@ -133,6 +133,20 @@
color: var(--gn-color-text-medium, #a9b1d6);
font-size: 0.85em;
}
+/* Иконка в строке подвала (0.83). Китовое `.ph.normalize` (top:.15em) рассчитано на
+ общую базовую линию: оно опускает глиф из 0.5em над базовой линией до середины
+ прописных. Но строка подвала («2 недели назад») — флекс с align-items:center, и
+ коробка иконки центрируется сама; у иконочного шрифта нет нижнего выноса
+ (fontBoundingBoxDescent = 0), поэтому низ коробки ложится на 0.125em ниже базовой
+ линии текста. Поправка складывается с этим центрированием, и глиф уезжает вниз на
+ 0.135em ≈ 1.6px — низ глифа уходит под базовую линию, и иконка читается провалившейся
+ (замер по пикселям на одной и той же строке: центр глифа на 1.3px ниже центра цифры
+ «2», рядом с которой он и стоит). Без поправки низ глифа Phosphor садится ровно на
+ базовую линию, как у буквы: расчёт даёт 0.375em против 0.36em середины прописных, то
+ есть 0.2px — иконке в текстовой строке больше и не нужно */
+.card-footer .ph.normalize {
+ top: 0;
+}
/* --- Дропдауны на мобильных: шторка снизу (0.77) --- */
/* Кит позиционирует меню от левого края триггера (position:absolute; left:0,
diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts
index 9536edf..285671b 100644
--- a/frontend/src/locales/en.ts
+++ b/frontend/src/locales/en.ts
@@ -366,6 +366,9 @@
// в другой — соседние пункты, это один вопрос «в каком проекте задача»
goToProject: 'Go to project',
moveToProject: 'Move to project',
+ // Card menu item (0.83): "Move to project" wrapped onto two lines in the kit's
+ // 220px menu, and inside the menu the question is "which project"
+ moveToOther: 'To another project',
moveHint: 'Task "{title}" will move to the selected project',
moved: 'Task moved to "{project}"',
movedOut: 'Task taken out of its project',
@@ -397,6 +400,16 @@
overdue: 'Overdue',
taskCount: '{n} tasks',
openSite: 'Open site',
+ // Project activity (0.83): the same GitHub-style grid as on the stats page,
+ // but over one project's tasks and with short ranges — the card sits in the sidebar
+ activity: 'Activity',
+ heatMonth: 'A month',
+ heatHalf: 'Half a year',
+ heatCell: '{date} — closed {done}, created {created}',
+ // Tag cloud (0.83): a click sets a substring filter by the tag name
+ tagShow: 'Show tasks tagged "{name}"',
+ tagHide: 'Clear the "{name}" tag filter',
+ tagCount: 'Tasks with this tag: {n}',
},
list: {
title: 'Task list',
diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts
index db07780..5e5bd04 100644
--- a/frontend/src/locales/ru.ts
+++ b/frontend/src/locales/ru.ts
@@ -366,6 +366,9 @@
// в другой — соседние пункты, это один вопрос «в каком проекте задача»
goToProject: 'Перейти к проекту',
moveToProject: 'Переместить в проект',
+ // Пункт меню карточки (0.83): «Переместить в проект» в китовом меню (220px)
+ // рвалось на две строки, а в самом меню вопрос звучит как «в какой проект»
+ moveToOther: 'В другой проект',
moveHint: 'Задача «{title}» уедет в выбранный проект',
moved: 'Задача перенесена в «{project}»',
movedOut: 'Задача вынута из проекта',
@@ -398,6 +401,16 @@
overdue: 'Просрочено',
taskCount: '{n} задач',
openSite: 'Открыть сайт',
+ // Активность проекта (0.83): та же сетка «как в GitHub», что в статистике,
+ // но по задачам одного проекта и с короткими периодами — карточка в сайдбаре
+ activity: 'Активность',
+ heatMonth: 'За месяц',
+ heatHalf: 'За полгода',
+ heatCell: '{date} — закрыто {done}, создано {created}',
+ // Облако тегов (0.83): клик по тегу ставит подстрочный фильтр по его имени
+ tagShow: 'Показать задачи с тегом «{name}»',
+ tagHide: 'Снять фильтр по тегу «{name}»',
+ tagCount: 'Задач с этим тегом: {n}',
},
list: {
title: 'Список задач',
diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts
index b7ac8ca..cb01eab 100644
--- a/frontend/src/locales/uk.ts
+++ b/frontend/src/locales/uk.ts
@@ -366,6 +366,9 @@
// в інший — сусідні пункти, це одне питання «у якому проєкті задача»
goToProject: 'Перейти до проєкту',
moveToProject: 'Перемістити до проєкту',
+ // Пункт меню картки (0.83): «Перемістити до проєкту» в китовому меню (220px)
+ // рвалося на два рядки, а в меню питання звучить як «до якого проєкту»
+ moveToOther: 'До іншого проєкту',
moveHint: 'Завдання «{title}» переїде до вибраного проєкту',
moved: 'Завдання перенесено до «{project}»',
movedOut: 'Завдання вилучено з проєкту',
@@ -397,6 +400,16 @@
overdue: 'Прострочено',
taskCount: '{n} задач',
openSite: 'Відкрити сайт',
+ // Активність проєкту (0.83): та сама сітка «як у GitHub», що в статистиці,
+ // але за завданнями одного проєкту й з короткими періодами — картка в сайдбарі
+ activity: 'Активність',
+ heatMonth: 'За місяць',
+ heatHalf: 'За півроку',
+ heatCell: '{date} — закрито {done}, створено {created}',
+ // Хмара теґів (0.83): клік по теґу ставить підрядковий фільтр за його іменем
+ tagShow: 'Показати завдання з теґом «{name}»',
+ tagHide: 'Зняти фільтр за теґом «{name}»',
+ tagCount: 'Завдань із цим теґом: {n}',
},
list: {
title: 'Список задач',
diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue
index 6611db0..564d0b2 100644
--- a/frontend/src/views/ProjectView.vue
+++ b/frontend/src/views/ProjectView.vue
@@ -18,6 +18,7 @@
import ConfirmDialog from '../components/ConfirmDialog.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskCard from '../components/TaskCard.vue'
+import ProjectHeatmap from '../components/ProjectHeatmap.vue'
import Pagination from '../components/Pagination.vue'
defineOptions({ name: 'ProjectView' })
@@ -236,6 +237,34 @@
return [...counts.entries()].sort((a, b) => b[1] - a[1])
})
+// Облако тегов (0.83) вместо списка: клик по тегу ставит фильтр. Размер имени и есть
+// счётчик — чем чаще тег в задачах проекта, тем крупнее (0.85…1.2em); точное число
+// осталось в подсказке, чтобы облако не превращалось в таблицу
+function tagFontSize(count: number): string {
+ const max = tagRows.value[0]?.[1] ?? 1
+ const min = tagRows.value[tagRows.value.length - 1]?.[1] ?? 1
+ if (max === min) return '1em'
+ return `${(0.85 + 0.35 * ((count - min) / (max - min))).toFixed(3)}em`
+}
+
+// Активным считаем тег, чьё имя стоит в поиске: фильтр подстрочный, поэтому
+// «bug» в поле подсвечивает и тег «bugfix», если он совпал целиком
+function isActiveTag(name: string): boolean {
+ return tagQuery.value.toLowerCase() === name.toLowerCase()
+}
+
+// Список задач на узком экране лежит выше сайдбара — после клика по тегу уводим
+// к нему, иначе результат фильтрации остался бы за кадром
+const listAnchor = ref(null)
+
+function filterByTag(name: string) {
+ // повторный клик по активному тегу снимает фильтр
+ filters.value.tag = isActiveTag(name) ? '' : name
+ if (window.matchMedia('(max-width: 960px)').matches) {
+ void nextTick(() => listAnchor.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
+ }
+}
+
const noteHtml = computed(() => (project.value ? renderMarkdown(project.value.note) : ''))
// README репозитория проекта (0.67) — второе описание. Раньше заметка из одной
@@ -955,7 +984,7 @@
:aria-label="t('list.filterStatus')"
@update:model-value="filters.status = $event"
/>
-
@@ -1608,6 +1657,38 @@
max-height: 400px;
overflow-y: auto;
}
+/* Облако тегов (0.83): размер имени — частота тега в задачах проекта, поэтому
+ строку держим по базовой линии, а не по центру коробок; gap в двух направлениях —
+ иначе крупные и мелкие чипы в одной строке липнут друг к другу */
+.tag-cloud {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.4rem 0.5rem;
+}
+/* Тег — кнопка: клик ставит фильтр по имени. Рамка и заливка — китовые токены,
+ те же, что у соседних карточек сайдбара */
+.tag-chip {
+ padding: 0 0.4rem;
+ background: none;
+ border: var(--gn-border-width-base, 2px) solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
+ border-radius: 3px;
+ font-family: inherit;
+ line-height: 1.6;
+ color: var(--gn-color-text-medium, #a9b1d6);
+ cursor: pointer;
+}
+.tag-chip:hover {
+ color: var(--accent, #7aa2f7);
+ border-color: var(--accent, #7aa2f7);
+}
+/* активный тег — тот, чьё имя стоит в поиске: фильтр видно и в облаке, а не
+ только в поле над списком */
+.tag-chip.active {
+ color: var(--accent, #7aa2f7);
+ border-color: var(--accent, #7aa2f7);
+ background: color-mix(in srgb, var(--accent, #7aa2f7) 15%, transparent);
+}
/* свёрнутая длинная заметка: видно начало, низ срезается */
.md-view.collapsed {
max-height: 420px;
@@ -1686,11 +1767,20 @@
.filters :deep(.select-wrap) {
margin-top: 0;
}
+/* Список задач — две карточки в ряд (0.83): в одну колонку карточка занимает всю
+ ширину основной колонки, а данных в ней на полстроки. Высоту ряда не выравниваем
+ руками: карточки тянутся (stretch по умолчанию), поэтому подвалы соседей стоят
+ на одной линии. На узком экране — снова одна колонка */
.task-list {
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
+@media (max-width: 700px) {
+ .task-list {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
.edit-form {
display: flex;
flex-direction: column;