diff --git a/core/db.py b/core/db.py
index dfc7047..bf1d10c 100644
--- a/core/db.py
+++ b/core/db.py
@@ -773,6 +773,12 @@ def _migrate(conn: sqlite3.Connection):
PRAGMA foreign_keys=ON;
""")
+ # KIN-126: Add completed_at to tasks — set when task transitions to 'done'
+ task_cols_final = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
+ if "completed_at" not in task_cols_final:
+ conn.execute("ALTER TABLE tasks ADD COLUMN completed_at DATETIME DEFAULT NULL")
+ conn.commit()
+
def _seed_default_hooks(conn: sqlite3.Connection):
"""Seed default hooks for the kin project (idempotent).
diff --git a/core/models.py b/core/models.py
index dde8f65..6d004f3 100644
--- a/core/models.py
+++ b/core/models.py
@@ -304,13 +304,15 @@ def list_tasks(
def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict:
- """Update task fields. Auto-sets updated_at."""
+ """Update task fields. Auto-sets updated_at. Sets completed_at when status transitions to 'done'."""
if not fields:
return get_task(conn, id)
json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels")
for key in json_cols:
if key in fields:
fields[key] = _json_encode(fields[key])
+ if "status" in fields and fields["status"] == "done":
+ fields["completed_at"] = datetime.now().isoformat()
fields["updated_at"] = datetime.now().isoformat()
sets = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [id]
diff --git a/tests/test_kin_126_regression.py b/tests/test_kin_126_regression.py
new file mode 100644
index 0000000..5c1a56d
--- /dev/null
+++ b/tests/test_kin_126_regression.py
@@ -0,0 +1,108 @@
+"""Regression tests for KIN-126 — фильтр по дате выполнения задач.
+
+Проверяет backend-поведение:
+1. Колонка completed_at добавляется в таблицу tasks при инициализации БД
+2. Новая задача имеет completed_at=None по умолчанию
+3. update_task(status='done') устанавливает completed_at как ISO-строку
+4. update_task(status='done') — completed_at является валидной ISO-строкой
+5. update_task(status='in_progress') не устанавливает completed_at
+6. update_task(status='cancelled') не устанавливает completed_at
+7. Обновление полей без смены статуса на done не трогает completed_at
+8. update_task(status='blocked') не устанавливает completed_at
+"""
+
+import pytest
+from datetime import datetime
+from core.db import init_db
+from core import models
+
+
+@pytest.fixture
+def conn():
+ """Fresh in-memory DB for each test."""
+ c = init_db(db_path=":memory:")
+ yield c
+ c.close()
+
+
+# ---------------------------------------------------------------------------
+# Schema
+# ---------------------------------------------------------------------------
+
+def test_schema_tasks_has_completed_at_column(conn):
+ """KIN-126: таблица tasks содержит колонку completed_at после инициализации БД."""
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
+ assert "completed_at" in cols, "KIN-126: колонка completed_at должна быть в таблице tasks"
+
+
+# ---------------------------------------------------------------------------
+# Default value
+# ---------------------------------------------------------------------------
+
+def test_new_task_completed_at_is_null(conn):
+ """KIN-126: новая задача имеет completed_at=None по умолчанию."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ t = models.create_task(conn, "P1-001", "p1", "Task")
+ assert t["completed_at"] is None
+
+
+# ---------------------------------------------------------------------------
+# Setting completed_at on status='done'
+# ---------------------------------------------------------------------------
+
+def test_update_task_to_done_sets_completed_at(conn):
+ """KIN-126: update_task(status='done') устанавливает completed_at."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ models.create_task(conn, "P1-001", "p1", "Task")
+ updated = models.update_task(conn, "P1-001", status="done")
+ assert updated["completed_at"] is not None
+
+
+def test_update_task_to_done_completed_at_is_valid_iso(conn):
+ """KIN-126: completed_at при status='done' — валидная ISO-строка datetime."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ models.create_task(conn, "P1-001", "p1", "Task")
+ updated = models.update_task(conn, "P1-001", status="done")
+ # Must parse without exception
+ parsed = datetime.fromisoformat(updated["completed_at"])
+ assert parsed is not None
+
+
+# ---------------------------------------------------------------------------
+# Non-done statuses do NOT set completed_at
+# ---------------------------------------------------------------------------
+
+def test_update_task_to_in_progress_does_not_set_completed_at(conn):
+ """KIN-126: update_task(status='in_progress') не устанавливает completed_at."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ models.create_task(conn, "P1-001", "p1", "Task")
+ updated = models.update_task(conn, "P1-001", status="in_progress")
+ assert updated["completed_at"] is None
+
+
+def test_update_task_to_cancelled_does_not_set_completed_at(conn):
+ """KIN-126: update_task(status='cancelled') не устанавливает completed_at."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ models.create_task(conn, "P1-001", "p1", "Task")
+ updated = models.update_task(conn, "P1-001", status="cancelled")
+ assert updated["completed_at"] is None
+
+
+def test_update_task_to_blocked_does_not_set_completed_at(conn):
+ """KIN-126: update_task(status='blocked') не устанавливает completed_at."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ models.create_task(conn, "P1-001", "p1", "Task")
+ updated = models.update_task(conn, "P1-001", status="blocked")
+ assert updated["completed_at"] is None
+
+
+# ---------------------------------------------------------------------------
+# Non-status updates do NOT touch completed_at
+# ---------------------------------------------------------------------------
+
+def test_update_task_priority_only_does_not_set_completed_at(conn):
+ """KIN-126: update_task с изменением только priority не трогает completed_at."""
+ models.create_project(conn, "p1", "P1", "/p1")
+ models.create_task(conn, "P1-001", "p1", "Task")
+ updated = models.update_task(conn, "P1-001", priority=1)
+ assert updated["completed_at"] is None
diff --git a/web/frontend/src/__tests__/date-filter.test.ts b/web/frontend/src/__tests__/date-filter.test.ts
new file mode 100644
index 0000000..869308a
--- /dev/null
+++ b/web/frontend/src/__tests__/date-filter.test.ts
@@ -0,0 +1,283 @@
+/**
+ * KIN-126: Тесты фильтра по дате выполнения задач (done tasks date range filter)
+ *
+ * AC: фильтр сделан и работает
+ *
+ * Проверяет:
+ * 1. Date filter inputs отображаются только когда выбран статус 'done'
+ * 2. Date filter inputs скрыты на других статусах
+ * 3. Date filter inputs скрыты без фильтра статуса
+ * 4. Фильтрация по dateFrom — задачи до этой даты исключаются
+ * 5. Фильтрация по dateTo — задачи после этой даты исключаются
+ * 6. Пустой результат при несовпадении дат
+ * 7. Сброс фильтра (кнопка ✕) показывает все done-задачи снова
+ * 8. Fallback на updated_at когда completed_at=null
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import ProjectView from '../views/ProjectView.vue'
+
+vi.mock('../api', () => ({
+ api: {
+ project: vi.fn(),
+ taskFull: vi.fn(),
+ runTask: vi.fn(),
+ auditProject: vi.fn(),
+ createTask: vi.fn(),
+ patchTask: vi.fn(),
+ patchProject: vi.fn(),
+ deployProject: vi.fn(),
+ },
+}))
+
+import { api } from '../api'
+
+const Stub = { template: '
' }
+
+function makeTask(
+ id: string,
+ status: string,
+ completedAt: string | null,
+ updatedAt = '2024-01-10T00:00:00',
+) {
+ return {
+ id,
+ project_id: 'KIN',
+ title: `Task ${id}`,
+ status,
+ priority: 5,
+ assigned_role: null,
+ parent_task_id: null,
+ brief: null,
+ spec: null,
+ created_at: '2024-01-01T00:00:00',
+ updated_at: updatedAt,
+ completed_at: completedAt,
+ }
+}
+
+const MOCK_PROJECT_DATE_FILTER = {
+ id: 'KIN',
+ name: 'Kin',
+ path: '/projects/kin',
+ status: 'active',
+ priority: 5,
+ tech_stack: ['python', 'vue'],
+ created_at: '2024-01-01',
+ total_tasks: 4,
+ done_tasks: 3,
+ active_tasks: 1,
+ blocked_tasks: 0,
+ review_tasks: 0,
+ tasks: [
+ makeTask('KIN-001', 'done', '2024-01-05T12:00:00'), // before range
+ makeTask('KIN-002', 'done', '2024-01-15T12:00:00'), // in middle
+ makeTask('KIN-003', 'done', '2024-01-25T12:00:00'), // after range
+ makeTask('KIN-004', 'pending', null),
+ ],
+ decisions: [],
+ modules: [],
+}
+
+function makeRouter() {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: Stub },
+ { path: '/project/:id', component: ProjectView, props: true },
+ ],
+ })
+}
+
+const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: (k: string) => store[k] ?? null,
+ setItem: (k: string, v: string) => { store[k] = v },
+ removeItem: (k: string) => { delete store[k] },
+ clear: () => { store = {} },
+ }
+})()
+Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
+
+beforeEach(() => {
+ localStorageMock.clear()
+ vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT_DATE_FILTER as any)
+})
+
+describe('KIN-126: фильтр по дате выполнения задач', () => {
+ it('1. Date filter inputs отображаются когда выбран статус done', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN?status=done')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ expect(
+ wrapper.find('[data-testid="date-from"]').exists(),
+ 'date-from должен быть виден при status=done',
+ ).toBe(true)
+ expect(
+ wrapper.find('[data-testid="date-to"]').exists(),
+ 'date-to должен быть виден при status=done',
+ ).toBe(true)
+ })
+
+ it('2. Date filter inputs скрыты когда done не выбран', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN?status=pending')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ expect(
+ wrapper.find('[data-testid="date-from"]').exists(),
+ 'date-from не должен быть виден при status=pending',
+ ).toBe(false)
+ expect(
+ wrapper.find('[data-testid="date-to"]').exists(),
+ 'date-to не должен быть виден при status=pending',
+ ).toBe(false)
+ })
+
+ it('3. Date filter inputs скрыты без фильтра статуса', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ expect(
+ wrapper.find('[data-testid="date-from"]').exists(),
+ 'date-from не должен быть виден без фильтра статуса',
+ ).toBe(false)
+ })
+
+ it('4. Фильтрация по dateFrom исключает задачи выполненные до этой даты', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN?status=done')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ await wrapper.find('[data-testid="date-from"]').setValue('2024-01-10')
+ await flushPromises()
+
+ const links = wrapper.findAll('a[href^="/task/"]')
+ const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
+
+ expect(ids, 'KIN-001 (2024-01-05) должен быть исключён').not.toContain('KIN-001')
+ expect(ids, 'KIN-002 (2024-01-15) должен быть виден').toContain('KIN-002')
+ expect(ids, 'KIN-003 (2024-01-25) должен быть виден').toContain('KIN-003')
+ })
+
+ it('5. Фильтрация по dateTo исключает задачи выполненные после этой даты', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN?status=done')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ await wrapper.find('[data-testid="date-to"]').setValue('2024-01-20')
+ await flushPromises()
+
+ const links = wrapper.findAll('a[href^="/task/"]')
+ const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
+
+ expect(ids, 'KIN-003 (2024-01-25) должен быть исключён').not.toContain('KIN-003')
+ expect(ids, 'KIN-001 (2024-01-05) должен быть виден').toContain('KIN-001')
+ expect(ids, 'KIN-002 (2024-01-15) должен быть виден').toContain('KIN-002')
+ })
+
+ it('6. Пустой результат когда ни одна задача не попадает в диапазон дат', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN?status=done')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ await wrapper.find('[data-testid="date-from"]').setValue('2024-02-01')
+ await wrapper.find('[data-testid="date-to"]').setValue('2024-02-28')
+ await flushPromises()
+
+ const links = wrapper.findAll('a[href^="/task/"]')
+ expect(links, 'Ни одна done-задача не должна отображаться при несовпадении дат').toHaveLength(0)
+ })
+
+ it('7. Клик по кнопке сброса очищает фильтр и показывает все done-задачи', async () => {
+ const router = makeRouter()
+ await router.push('/project/KIN?status=done')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ // Устанавливаем dateFrom — остаётся только одна done-задача
+ await wrapper.find('[data-testid="date-from"]').setValue('2024-01-20')
+ await flushPromises()
+ expect(wrapper.findAll('a[href^="/task/"]')).toHaveLength(1)
+
+ // Кнопка сброса — ✕ без data-action (не clear-status)
+ const resetBtn = wrapper.findAll('button').find(
+ b => b.text() === '✕' && !b.attributes('data-action'),
+ )
+ expect(resetBtn?.exists(), 'Кнопка сброса дат должна появиться').toBe(true)
+ await resetBtn!.trigger('click')
+ await flushPromises()
+
+ // После сброса — все 3 done-задачи видны
+ expect(wrapper.findAll('a[href^="/task/"]')).toHaveLength(3)
+ })
+
+ it('8. Fallback на updated_at когда completed_at=null', async () => {
+ const projectWithNullCompletedAt = {
+ ...MOCK_PROJECT_DATE_FILTER,
+ tasks: [
+ makeTask('KIN-010', 'done', null, '2024-01-05T00:00:00'), // updated_at early
+ makeTask('KIN-011', 'done', null, '2024-01-25T00:00:00'), // updated_at late
+ ],
+ }
+ vi.mocked(api.project).mockResolvedValue(projectWithNullCompletedAt as any)
+
+ const router = makeRouter()
+ await router.push('/project/KIN?status=done')
+
+ const wrapper = mount(ProjectView, {
+ props: { id: 'KIN' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ // dateFrom='2024-01-20' → KIN-010 (updated_at 2024-01-05) должен быть исключён
+ await wrapper.find('[data-testid="date-from"]').setValue('2024-01-20')
+ await flushPromises()
+
+ const links = wrapper.findAll('a[href^="/task/"]')
+ const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
+
+ expect(ids, 'KIN-010 должен быть исключён (updated_at до dateFrom)').not.toContain('KIN-010')
+ expect(ids, 'KIN-011 должен быть виден (updated_at после dateFrom)').toContain('KIN-011')
+ })
+})
diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts
index 97c6fe5..fe44d66 100644
--- a/web/frontend/src/api.ts
+++ b/web/frontend/src/api.ts
@@ -122,6 +122,7 @@ export interface Task {
feedback?: string | null
created_at: string
updated_at: string
+ completed_at?: string | null
}
export interface Decision {
diff --git a/web/frontend/src/locales/en.json b/web/frontend/src/locales/en.json
index 9a8724b..a3eab4f 100644
--- a/web/frontend/src/locales/en.json
+++ b/web/frontend/src/locales/en.json
@@ -221,7 +221,9 @@
"settings_integrations_section": "Integrations",
"settings_execution_mode": "Execution mode",
"settings_autocommit": "Autocommit",
- "settings_autocommit_hint": "— git commit after pipeline"
+ "settings_autocommit_hint": "— git commit after pipeline",
+ "done_date_from": "From",
+ "done_date_to": "To"
},
"escalation": {
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
diff --git a/web/frontend/src/locales/ru.json b/web/frontend/src/locales/ru.json
index 0eff34b..81c72e6 100644
--- a/web/frontend/src/locales/ru.json
+++ b/web/frontend/src/locales/ru.json
@@ -221,7 +221,9 @@
"settings_integrations_section": "Интеграции",
"settings_execution_mode": "Режим выполнения",
"settings_autocommit": "Автокоммит",
- "settings_autocommit_hint": "— git commit после pipeline"
+ "settings_autocommit_hint": "— git commit после pipeline",
+ "done_date_from": "От",
+ "done_date_to": "До"
},
"escalation": {
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue
index 46051f8..5f13d48 100644
--- a/web/frontend/src/views/ProjectView.vue
+++ b/web/frontend/src/views/ProjectView.vue
@@ -186,6 +186,8 @@ function initStatusFilter(): string[] {
const selectedStatuses = ref(initStatusFilter())
const selectedCategory = ref('')
const taskSearch = ref('')
+const dateFrom = ref('')
+const dateTo = ref('')
function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s)
@@ -659,6 +661,16 @@ const filteredTasks = computed(() => {
let tasks = searchFilteredTasks.value
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value)
+ if ((dateFrom.value || dateTo.value) && selectedStatuses.value.includes('done')) {
+ tasks = tasks.filter(t => {
+ if (t.status !== 'done') return true
+ const dateStr = (t.completed_at || t.updated_at) ?? ''
+ const d = dateStr.substring(0, 10)
+ if (dateFrom.value && d < dateFrom.value) return false
+ if (dateTo.value && d > dateTo.value) return false
+ return true
+ })
+ }
return tasks
})
@@ -1077,6 +1089,17 @@ async function addDecision() {
+
+
+ {{ t('projectView.done_date_from') }}
+
+ {{ t('projectView.done_date_to') }}
+
+
+