diff --git a/core/db.py b/core/db.py index bf1d10c..dfc7047 100644 --- a/core/db.py +++ b/core/db.py @@ -773,12 +773,6 @@ 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 6d004f3..dde8f65 100644 --- a/core/models.py +++ b/core/models.py @@ -304,15 +304,13 @@ def list_tasks( def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict: - """Update task fields. Auto-sets updated_at. Sets completed_at when status transitions to 'done'.""" + """Update task fields. Auto-sets updated_at.""" 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 deleted file mode 100644 index 5c1a56d..0000000 --- a/tests/test_kin_126_regression.py +++ /dev/null @@ -1,108 +0,0 @@ -"""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 deleted file mode 100644 index 869308a..0000000 --- a/web/frontend/src/__tests__/date-filter.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * 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 fe44d66..97c6fe5 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -122,7 +122,6 @@ 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 a3eab4f..9a8724b 100644 --- a/web/frontend/src/locales/en.json +++ b/web/frontend/src/locales/en.json @@ -221,9 +221,7 @@ "settings_integrations_section": "Integrations", "settings_execution_mode": "Execution mode", "settings_autocommit": "Autocommit", - "settings_autocommit_hint": "— git commit after pipeline", - "done_date_from": "From", - "done_date_to": "To" + "settings_autocommit_hint": "— git commit after pipeline" }, "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 81c72e6..0eff34b 100644 --- a/web/frontend/src/locales/ru.json +++ b/web/frontend/src/locales/ru.json @@ -221,9 +221,7 @@ "settings_integrations_section": "Интеграции", "settings_execution_mode": "Режим выполнения", "settings_autocommit": "Автокоммит", - "settings_autocommit_hint": "— git commit после pipeline", - "done_date_from": "От", - "done_date_to": "До" + "settings_autocommit_hint": "— git commit после pipeline" }, "escalation": { "watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}", diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 5f13d48..46051f8 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -186,8 +186,6 @@ 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) @@ -661,16 +659,6 @@ 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 }) @@ -1089,17 +1077,6 @@ async function addDecision() {
- -
- {{ t('projectView.done_date_from') }} - - {{ t('projectView.done_date_to') }} - - -