diff --git a/CLAUDE.md b/CLAUDE.md index 44023a5..3227706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,10 @@ ESM-пакет, Node 22+. SDK — `@modelcontextprotocol/sdk` (`McpServer` + `St **Route-коллизии под общим префиксом.** Несколько контроллеров могут делить один префикс (например `@Controller('goals')` в `GoalModule` и в `ReportModule`). Тогда `@Get(':id')` одного контроллера перехватывает одиночный литерал (`goals/report-queue`) другого, и `ParseIntPipe` отдаёт 400. Порядок матчинга = import-порядку модулей в `AppModule` — полагаться на него хрупко. Express 5 / path-to-regexp v8 **не поддерживают** inline-regex `:id(\d+)` (приложение не стартует). Решение: давать литеральным маршрутам **многосегментный** путь, который одиночный `:id` не может захватить (`goals/reports/queue`, не `goals/report-queue`). Регрессия — `alfy-bot/test/web-goal-reports-routing.e2e-spec.ts`. +**Env для e2e ставится в `setupFiles`, не в хелпере.** `AppModule` решает, поднимать ли Telegraf, на этапе **импорта модуля** (`const telegramImports = isTelegramEnabled()`). Любой `process.env.ENABLE_TELEGRAM = 'false'` внутри `createTestApp()` опаздывает: `import { AppModule }` в шапке спеки уже отработал, бот стартует и валит весь suite с `401: Bot Token is required`. Поэтому переменные живут в `alfy-bot/test/setup-e2e-env.ts`, подключённом через `setupFiles` в `test/jest-e2e.json`. Не переносить их обратно в хелпер. + +**Эндпоинты, возвращающие `UpdateTaskResponse`.** `PATCH /tasks/:id` и `PATCH /tasks/:id/pomodoro` отдают не задачу, а обёртку `{ task, nextInstance?, deletedInstanceId? }` — из-за повторяющихся задач, где завершение порождает следующий инстанс. Потребители должны читать `body.task`, а не `body`. На фронте разбор один — `applyUpdateResponse` в `features/tasks/model/task-store.ts`. + # Frontend architecture (FSD-like) `alfy-bot-frontend/src/` устроен как feature-sliced: diff --git a/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts b/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts index e8bcd38..029f7a0 100644 --- a/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts +++ b/alfy-bot-frontend/src/features/task-timer/model/timer-store.ts @@ -1,6 +1,7 @@ import { ref, computed, type Ref } from 'vue' import { defineStore } from 'pinia' import { api } from '@/api/client' +import { useTaskStore } from '@/features/tasks/model/task-store' import type { TimerSettings, TimerSession, PhaseInfo, SessionState, Task, TimerSWMessage } from '../types' import { useSounds } from '@/composables/useSounds' @@ -164,11 +165,9 @@ export const useTimerStore = defineStore('timer', () => { if (fraction <= 0) return - try { - await api.patch(`/tasks/${taskId}/pomodoro`, { increment: fraction }) - } catch (err) { - console.error('Ошибка сохранения помодоро:', err) - } + // Delegated so the store applies the response — the backend may auto-complete + // the task once its pomodoro target is reached. Error handling lives there. + await useTaskStore().incrementPomodoro(taskId, fraction) } function calculateSeconds(minutes: number): number { diff --git a/alfy-bot-frontend/src/features/tasks/model/task-store.ts b/alfy-bot-frontend/src/features/tasks/model/task-store.ts index a8b4b72..4624aa7 100644 --- a/alfy-bot-frontend/src/features/tasks/model/task-store.ts +++ b/alfy-bot-frontend/src/features/tasks/model/task-store.ts @@ -47,6 +47,42 @@ export const useTaskStore = defineStore('tasks', () => { const loading = ref(false) const error = ref(null) + /** + * Apply a backend UpdateTaskResponse — { task, nextInstance?, deletedInstanceId? } — + * to the store. Shared by every endpoint that returns that shape. + */ + const applyUpdateResponse = (response: Record, taskId: string): Task => { + const taskData = response.task ? response.task as Record : response + const updatedTask = parseTask(taskData) + + const index = tasks.value.findIndex(t => t.id === taskId) + if (index !== -1) { + const existing = tasks.value[index]! + tasks.value[index] = { ...existing, ...updatedTask, checklist: existing.checklist } + } + + // Handle recurring: add or refresh next instance in store. + // On complete -> brand-new instance; on uncomplete -> promoted existing instance with updated fields. + if (response.nextInstance) { + const nextInstance = parseTask(response.nextInstance as Record) + const existingIndex = tasks.value.findIndex(t => t.id === nextInstance.id) + if (existingIndex === -1) { + tasks.value.unshift(nextInstance) + } else { + const existing = tasks.value[existingIndex]! + tasks.value[existingIndex] = { ...existing, ...nextInstance, checklist: existing.checklist } + } + } + + // Handle recurring: remove deleted instance from store + if (response.deletedInstanceId) { + const deletedId = response.deletedInstanceId as string + tasks.value = tasks.value.filter(t => t.id !== deletedId) + } + + return updatedTask + } + const fetchTasks = async () => { loading.value = true error.value = null @@ -119,37 +155,7 @@ export const useTaskStore = defineStore('tasks', () => { } = updates as Record const { data } = await api.patch(`/tasks/${taskId}`, serializeTaskDates(rest)) - // Backend returns UpdateTaskResponse: { task, nextInstance?, deletedInstanceId? } - const response = data as Record - const taskData = response.task ? response.task as Record : response - const updatedTask = parseTask(taskData) - - const index = tasks.value.findIndex(t => t.id === taskId) - if (index !== -1) { - const existing = tasks.value[index]! - tasks.value[index] = { ...existing, ...updatedTask, checklist: existing.checklist } - } - - // Handle recurring: add or refresh next instance in store. - // On complete -> brand-new instance; on uncomplete -> promoted existing instance with updated fields. - if (response.nextInstance) { - const nextInstance = parseTask(response.nextInstance as Record) - const existingIndex = tasks.value.findIndex(t => t.id === nextInstance.id) - if (existingIndex === -1) { - tasks.value.unshift(nextInstance) - } else { - const existing = tasks.value[existingIndex]! - tasks.value[existingIndex] = { ...existing, ...nextInstance, checklist: existing.checklist } - } - } - - // Handle recurring: remove deleted instance from store - if (response.deletedInstanceId) { - const deletedId = response.deletedInstanceId as string - tasks.value = tasks.value.filter(t => t.id !== deletedId) - } - - return updatedTask + return applyUpdateResponse(data as Record, taskId) } catch (err) { if (setLoading) { error.value = err instanceof Error ? err.message : 'Ошибка обновления задачи' @@ -195,15 +201,21 @@ export const useTaskStore = defineStore('tasks', () => { } const incrementPomodoro = async (taskId: string, increment: number) => { + // The task may not be in the store yet (the timer restores its session before + // fetchTasks resolves) — the increment must still reach the backend, so only + // the optimistic bump is conditional. const task = tasks.value.find(t => t.id === taskId) - if (!task || !task.isPomodoroTask) return + const previousPomodoroCompleted = task?.pomodoroCompleted ?? 0 - task.pomodoroCompleted = Math.round(((task.pomodoroCompleted || 0) + increment) * 100) / 100 + if (task) { + task.pomodoroCompleted = Math.round((previousPomodoroCompleted + increment) * 100) / 100 + } try { - await api.patch(`/tasks/${taskId}/pomodoro`, { increment }) + const { data } = await api.patch(`/tasks/${taskId}/pomodoro`, { increment }) + return applyUpdateResponse(data as Record, taskId) } catch (err) { - task.pomodoroCompleted = Math.round(((task.pomodoroCompleted || 0) - increment) * 100) / 100 + if (task) task.pomodoroCompleted = previousPomodoroCompleted console.error('Ошибка сохранения помодоро:', err) } } diff --git a/alfy-bot-frontend/tests/features/task-timer/timer-store-pomodoro.spec.ts b/alfy-bot-frontend/tests/features/task-timer/timer-store-pomodoro.spec.ts new file mode 100644 index 0000000..0a4cb39 --- /dev/null +++ b/alfy-bot-frontend/tests/features/task-timer/timer-store-pomodoro.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useTimerStore } from '@/features/task-timer/model/timer-store' +import { useTaskStore } from '@/features/tasks/model/task-store' +import { api } from '@/api/client' + +vi.mock('@/api/client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + put: vi.fn(), + }, +})) + +const POMODORO_TASK = { + id: 'task-1', + title: 'Задача', + pomodoroTime: 25, + breakTime: 5, + longBreakTime: 15, + longBreakInterval: 4, + pomodoroCount: 4, +} + +describe('timer store — запись помодоро', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.mocked(api.put).mockResolvedValue({ data: {} }) + vi.mocked(api.delete).mockResolvedValue({ data: {} }) + }) + + it('завершение рабочей фазы делегирует инкремент в task-store', async () => { + const timer = useTimerStore() + const tasks = useTaskStore() + const spy = vi.spyOn(tasks, 'incrementPomodoro').mockResolvedValue(undefined) + + timer.startTask(POMODORO_TASK) + // Phase 1 is work; half of it elapsed. + timer.timeBlock = timer.getPhaseInfo(1).time / 2 + timer.stopTimeBlock() + await Promise.resolve() + + expect(spy).toHaveBeenCalledWith('task-1', 0.5) + }) + + it('не ходит в /tasks/:id/pomodoro напрямую', async () => { + const timer = useTimerStore() + const tasks = useTaskStore() + vi.spyOn(tasks, 'incrementPomodoro').mockResolvedValue(undefined) + + timer.startTask(POMODORO_TASK) + timer.timeBlock = timer.getPhaseInfo(1).time / 2 + timer.stopTimeBlock() + await Promise.resolve() + + const pomodoroCalls = vi + .mocked(api.patch) + .mock.calls.filter(([url]) => String(url).includes('/pomodoro')) + expect(pomodoroCalls).toHaveLength(0) + }) + + it('завершение фазы перерыва помодоро не записывает', async () => { + const timer = useTimerStore() + const tasks = useTaskStore() + const spy = vi.spyOn(tasks, 'incrementPomodoro').mockResolvedValue(undefined) + + timer.startTask(POMODORO_TASK) + timer.nextPhase(2) // phase 2 is a break + timer.timeBlock = timer.getPhaseInfo(2).time / 2 + timer.stopTimeBlock() + await Promise.resolve() + + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/alfy-bot-frontend/tests/features/tasks/model/task-store-pomodoro.spec.ts b/alfy-bot-frontend/tests/features/tasks/model/task-store-pomodoro.spec.ts new file mode 100644 index 0000000..da5abb6 --- /dev/null +++ b/alfy-bot-frontend/tests/features/tasks/model/task-store-pomodoro.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useTaskStore } from '@/features/tasks/model/task-store' +import { api } from '@/api/client' +import type { Task } from '@/features/tasks/model/types' + +vi.mock('@/api/client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + put: vi.fn(), + }, +})) + +/** Raw backend shape — pomodoro fields live under pomodoroConfig, not on the task. */ +function rawTask(overrides: Record = {}) { + return { + id: 'task-1', + title: 'Задача', + completed: false, + pomodoroConfig: { + pomodoroCount: 4, + pomodoroDuration: 25, + shortBreak: 5, + longBreak: 15, + longBreakInterval: 4, + pomodoroCompleted: 3, + }, + ...overrides, + } +} + +describe('task store incrementPomodoro', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + async function seedStore(raw: Record[] = [rawTask()]) { + vi.mocked(api.get).mockResolvedValue({ data: raw }) + const store = useTaskStore() + await store.fetchTasks() + return store + } + + it('оптимистично бампает pomodoroCompleted до ответа', async () => { + const store = await seedStore() + vi.mocked(api.patch).mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ data: { task: rawTask() } } as never), 100)), + ) + + const promise = store.incrementPomodoro('task-1', 0.6) + + expect(store.tasks[0]!.pomodoroCompleted).toBe(3.6) + + await promise + }) + + it('отправляет PATCH /tasks/:id/pomodoro с increment', async () => { + const store = await seedStore() + vi.mocked(api.patch).mockResolvedValue({ data: { task: rawTask() } }) + + await store.incrementPomodoro('task-1', 1) + + expect(api.patch).toHaveBeenCalledWith('/tasks/task-1/pomodoro', { increment: 1 }) + }) + + it('применяет автозакрытие из ответа бэкенда', async () => { + const store = await seedStore() + vi.mocked(api.patch).mockResolvedValue({ + data: { + task: rawTask({ + completed: true, + pomodoroConfig: { pomodoroCount: 4, pomodoroCompleted: 4 }, + }), + }, + }) + + await store.incrementPomodoro('task-1', 1) + + expect(store.tasks[0]!.completed).toBe(true) + expect(store.tasks[0]!.pomodoroCompleted).toBe(4) + }) + + it('добавляет nextInstance из ответа в стор', async () => { + const store = await seedStore() + vi.mocked(api.patch).mockResolvedValue({ + data: { + task: rawTask({ completed: true }), + nextInstance: rawTask({ id: 'task-2', title: 'Следующий инстанс' }), + }, + }) + + await store.incrementPomodoro('task-1', 1) + + expect(store.tasks.find((t: Task) => t.id === 'task-2')).toBeDefined() + }) + + it('откатывает бамп при ошибке API', async () => { + const store = await seedStore() + vi.mocked(api.patch).mockRejectedValue(new Error('Ошибка')) + + await store.incrementPomodoro('task-1', 0.6) + + expect(store.tasks[0]!.pomodoroCompleted).toBe(3) + }) + + it('отправляет запрос даже если задачи нет в локальном сторе', async () => { + const store = await seedStore([]) + vi.mocked(api.patch).mockResolvedValue({ data: { task: rawTask() } }) + + await store.incrementPomodoro('missing-task', 1) + + expect(api.patch).toHaveBeenCalledWith('/tasks/missing-task/pomodoro', { increment: 1 }) + }) +}) diff --git a/alfy-bot/src/modules/task/domain/pomodoro.utils.spec.ts b/alfy-bot/src/modules/task/domain/pomodoro.utils.spec.ts new file mode 100644 index 0000000..ec8c7e5 --- /dev/null +++ b/alfy-bot/src/modules/task/domain/pomodoro.utils.spec.ts @@ -0,0 +1,35 @@ +import { hasCrossedPomodoroTarget } from './pomodoro.utils'; + +describe('hasCrossedPomodoroTarget', () => { + it('полный переход через порог', () => { + expect(hasCrossedPomodoroTarget(3.0, 4.0, 4)).toBe(true); + }); + + it('дробный переход через порог', () => { + expect(hasCrossedPomodoroTarget(3.6, 4.1, 4)).toBe(true); + }); + + it('не дошёл до порога', () => { + expect(hasCrossedPomodoroTarget(3.0, 3.6, 4)).toBe(false); + }); + + it('уже был ровно на пороге — не перезакрывает', () => { + expect(hasCrossedPomodoroTarget(4.0, 5.0, 4)).toBe(false); + }); + + it('уже был выше порога — не перезакрывает', () => { + expect(hasCrossedPomodoroTarget(5.0, 6.0, 4)).toBe(false); + }); + + it('target = 0 — правило не применяется', () => { + expect(hasCrossedPomodoroTarget(0, 1, 0)).toBe(false); + }); + + it('float-дребезг снизу: after чуть меньше порога считается достижением', () => { + expect(hasCrossedPomodoroTarget(3.9, 3.9999999, 4)).toBe(true); + }); + + it('float-дребезг на пороге: before чуть меньше порога считается уже пройденным', () => { + expect(hasCrossedPomodoroTarget(3.9999999, 4.5, 4)).toBe(false); + }); +}); diff --git a/alfy-bot/src/modules/task/domain/pomodoro.utils.ts b/alfy-bot/src/modules/task/domain/pomodoro.utils.ts new file mode 100644 index 0000000..b6bdc21 --- /dev/null +++ b/alfy-bot/src/modules/task/domain/pomodoro.utils.ts @@ -0,0 +1,23 @@ +/** + * Tolerance for the float accumulation of fractional pomodoro increments. + * `pomodoroCompleted` is a `real` column summed via SQL `x = x + n`, so a value + * that logically equals the target may be stored as 3.9999999. + */ +export const POMODORO_EPSILON = 1e-6; + +/** + * True when this increment moved the task across its pomodoro target. + * + * Deliberately a transition check, not a `after >= target` check: a task whose + * counter is already at or above the target must not be re-completed, which is + * what lets a user uncheck an auto-completed task and keep working on it. + */ +export function hasCrossedPomodoroTarget( + before: number, + after: number, + target: number, +): boolean { + if (target <= 0) return false; + const threshold = target - POMODORO_EPSILON; + return before < threshold && after >= threshold; +} diff --git a/alfy-bot/src/modules/task/task.controller.ts b/alfy-bot/src/modules/task/task.controller.ts index 0650a9c..87a1520 100644 --- a/alfy-bot/src/modules/task/task.controller.ts +++ b/alfy-bot/src/modules/task/task.controller.ts @@ -157,12 +157,15 @@ export class TaskController { } @Patch(':id/pomodoro') - @ApiOperation({ summary: 'Инкремент выполненных помодоро' }) + @ApiOperation({ + summary: + 'Инкремент выполненных помодоро; автозакрывает задачу при достижении цели', + }) async incrementPomodoro( @Request() req: AuthRequest, @Param('id') id: string, @Body() body: { increment: number }, - ) { - await this.taskService.incrementPomodoro(req.user.sub, id, body.increment); + ): Promise { + return this.taskService.incrementPomodoro(req.user.sub, id, body.increment); } } diff --git a/alfy-bot/src/modules/task/task.service.spec.ts b/alfy-bot/src/modules/task/task.service.spec.ts index 687889e..e50fc2c 100644 --- a/alfy-bot/src/modules/task/task.service.spec.ts +++ b/alfy-bot/src/modules/task/task.service.spec.ts @@ -317,6 +317,104 @@ describe('TaskService', () => { NotFoundException, ); }); + + // findById is read twice (before/after the increment); a third read happens + // inside update() when the task auto-completes. + const seedIncrement = (before: Task, after: Task) => { + repo.findById + .mockResolvedValueOnce(before) + .mockResolvedValueOnce(after) + .mockResolvedValue(after); + }; + + const pomodoroTask = ( + pomodoroCompleted: number, + overrides: Partial = {}, + ) => + makeTask({ + pomodoroConfig: makePomodoroConfig({ + pomodoroCount: 4, + pomodoroCompleted, + }), + ...overrides, + }); + + it('пересечение порога закрывает задачу', async () => { + seedIncrement(pomodoroTask(3), pomodoroTask(4)); + + const result = await service.incrementPomodoro(1, 'task-1', 1); + + expect(result.task.completed).toBe(true); + expect(repo.save).toHaveBeenCalled(); + }); + + it('порог не достигнут — возвращает свежий счётчик, не закрывая задачу', async () => { + seedIncrement(pomodoroTask(3), pomodoroTask(3.6)); + + const result = await service.incrementPomodoro(1, 'task-1', 0.6); + + expect(result.task.completed).toBe(false); + expect(result.task.pomodoroConfig?.pomodoroCompleted).toBe(3.6); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('уже выполненная задача не закрывается повторно', async () => { + seedIncrement( + pomodoroTask(3, { completed: true }), + pomodoroTask(4, { completed: true }), + ); + + await service.incrementPomodoro(1, 'task-1', 1); + + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('overdue задача не закрывается и не бросает', async () => { + seedIncrement( + pomodoroTask(3, { isOverdue: true }), + pomodoroTask(4, { isOverdue: true }), + ); + + const result = await service.incrementPomodoro(1, 'task-1', 1); + + expect(result.task.completed).toBe(false); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('задача без pomodoroConfig не закрывается', async () => { + seedIncrement(makeTask(), makeTask()); + + const result = await service.incrementPomodoro(1, 'task-1', 1); + + expect(result.task.completed).toBe(false); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('recurring задача при пересечении порога порождает следующий instance', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-04-05T10:00:00.000Z')); + + const recurring = (pomodoroCompleted: number) => + pomodoroTask(pomodoroCompleted, { + id: 'root-1', + recurrence: { frequency: 'daily', interval: 1 } as RecurrenceRule, + dueDate: new Date('2026-04-05T10:00:00.000Z'), + recurringParentId: null, + recurringCompletedCount: 0, + }); + seedIncrement(recurring(3), recurring(4)); + + const result = await service.incrementPomodoro(1, 'root-1', 1); + + expect(result.task.completed).toBe(true); + expect(repo.create).toHaveBeenCalledTimes(1); + const instanceArg = repo.create.mock.calls[0][0] as Partial; + expect(instanceArg.recurringParentId).toBe('root-1'); + expect(instanceArg.pomodoroConfig?.pomodoroCompleted).toBe(0); + expect(result.nextInstance).toBeDefined(); + + jest.useRealTimers(); + }); }); describe('delete', () => { diff --git a/alfy-bot/src/modules/task/task.service.ts b/alfy-bot/src/modules/task/task.service.ts index 3414d44..8879902 100644 --- a/alfy-bot/src/modules/task/task.service.ts +++ b/alfy-bot/src/modules/task/task.service.ts @@ -16,6 +16,7 @@ import { buildNextInstance, findNextOccurrenceOnOrAfter, } from './domain/recurrence.utils'; +import { hasCrossedPomodoroTarget } from './domain/pomodoro.utils'; import { UserSettingsPort } from './domain/user-settings.port'; import { shiftToUserWallClock, shiftBackToUtc } from './lib/timezone'; @@ -274,10 +275,33 @@ export class TaskService { userId: number, taskId: string, increment: number, - ): Promise { + ): Promise { const task = await this.taskRepo.findById(taskId, userId); if (!task) throw new NotFoundException(`Task #${taskId} not found`); + + const before = task.pomodoroConfig?.pomodoroCompleted ?? 0; + const target = task.pomodoroConfig?.pomodoroCount ?? 0; + await this.taskRepo.incrementPomodoroCompleted(taskId, increment); + + // Re-read for the authoritative counter: the increment is applied by SQL, + // and the fresh value is what the client needs for its X/Y badge. + const refreshed = (await this.taskRepo.findById(taskId, userId)) ?? task; + const after = refreshed.pomodoroConfig?.pomodoroCompleted ?? before; + + // The increment is the primary effect — guard so auto-completion can never + // throw on top of a counter that is already persisted. + const shouldAutoComplete = + !!task.pomodoroConfig && + !task.completed && + !task.isOverdue && + hasCrossedPomodoroTarget(before, after, target); + + if (shouldAutoComplete) { + return this.update(userId, taskId, { completed: true }); + } + + return { task: refreshed }; } async delete(userId: number, id: string): Promise { diff --git a/alfy-bot/test/jest-e2e.json b/alfy-bot/test/jest-e2e.json index e9d912f..94e5f86 100644 --- a/alfy-bot/test/jest-e2e.json +++ b/alfy-bot/test/jest-e2e.json @@ -3,6 +3,7 @@ "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", + "setupFiles": ["/setup-e2e-env.ts"], "transform": { "^.+\\.(t|j)s$": "ts-jest" } diff --git a/alfy-bot/test/setup-e2e-env.ts b/alfy-bot/test/setup-e2e-env.ts new file mode 100644 index 0000000..c7854bf --- /dev/null +++ b/alfy-bot/test/setup-e2e-env.ts @@ -0,0 +1,7 @@ +// Telegram wiring is decided at AppModule *import* time (see `telegramImports` +// in src/app.module.ts), which runs before any beforeAll hook — so setting these +// inside createTestApp() is too late and telegraf launches against the real API. +// setupFiles runs before the test files are imported, which is early enough. +process.env.ENABLE_TELEGRAM = 'false'; +process.env.BOT_TOKEN = 'test-bot-token'; +process.env.JWT_SECRET = 'test-secret'; diff --git a/alfy-bot/test/tasks.e2e-spec.ts b/alfy-bot/test/tasks.e2e-spec.ts index 8c934e3..69a7a74 100644 --- a/alfy-bot/test/tasks.e2e-spec.ts +++ b/alfy-bot/test/tasks.e2e-spec.ts @@ -156,7 +156,7 @@ describe('Tasks (e2e)', () => { .send({ title: 'Updated title' }) .expect(200); - expect(body.title).toBe('Updated title'); + expect(body.task.title).toBe('Updated title'); }); it('toggles completed', async () => { @@ -166,7 +166,7 @@ describe('Tasks (e2e)', () => { .send({ completed: true }) .expect(200); - expect(body.completed).toBe(true); + expect(body.task.completed).toBe(true); }); it('updates dates', async () => { @@ -176,7 +176,57 @@ describe('Tasks (e2e)', () => { .send({ dueDate: '2026-04-01T00:00:00.000Z' }) .expect(200); - expect(body.dueDate).toBeTruthy(); + expect(body.task.dueDate).toBeTruthy(); + }); + }); + + describe('PATCH /api/tasks/:id/pomodoro', () => { + let taskId: string; + + beforeAll(async () => { + const { body } = await request(app.getHttpServer()) + .post('/api/tasks') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Two pomodoros', + isPomodoroTask: true, + pomodoroCount: 2, + pomodoroDuration: 25, + }) + .expect(201); + taskId = body.id; + }); + + it('first increment leaves the task open', async () => { + const { body } = await request(app.getHttpServer()) + .patch(`/api/tasks/${taskId}/pomodoro`) + .set('Authorization', `Bearer ${token}`) + .send({ increment: 1 }) + .expect(200); + + expect(body.task.completed).toBe(false); + expect(body.task.pomodoroConfig.pomodoroCompleted).toBe(1); + }); + + it('increment reaching pomodoroCount completes the task', async () => { + const { body } = await request(app.getHttpServer()) + .patch(`/api/tasks/${taskId}/pomodoro`) + .set('Authorization', `Bearer ${token}`) + .send({ increment: 1 }) + .expect(200); + + expect(body.task.completed).toBe(true); + }); + + it('further increments keep counting without re-completing', async () => { + const { body } = await request(app.getHttpServer()) + .patch(`/api/tasks/${taskId}/pomodoro`) + .set('Authorization', `Bearer ${token}`) + .send({ increment: 1 }) + .expect(200); + + expect(body.task.completed).toBe(true); + expect(body.task.pomodoroConfig.pomodoroCompleted).toBe(3); }); }); diff --git a/docs/tasks/auto-complete-task-on-pomodoros-done.md b/docs/tasks/auto-complete-task-on-pomodoros-done.md new file mode 100644 index 0000000..521a80f --- /dev/null +++ b/docs/tasks/auto-complete-task-on-pomodoros-done.md @@ -0,0 +1,258 @@ +# Автозакрытие задачи при выполнении всех помидоров + +**Status:** done +**Branch:** feat/auto-complete-task-on-pomodoros-done +**Worktree:** /Users/v/projects/Alfy-worktrees/auto-complete-pomodoros +**Mode:** interactive + +## Design + +### Цель + +Когда накопленное число помидоров по задаче достигает запланированного (`pomodoroCompleted >= pomodoroCount` — ровно то, что показывает бейдж `X/Y`), задача автоматически становится выполненной. В календаре она отображается как выполненная. + +### Что уже есть (разведка) + +- Единственный путь инкремента — `timer-store.incrementPomodoro()` (`alfy-bot-frontend/src/features/task-timer/model/timer-store.ts:157`) → `PATCH /tasks/:id/pomodoro`. Ни телеграм-бот, ни `alfy-mcp` помидоры не трогают. +- Инкремент дробный: `fraction = elapsed / phaseTime`, округлённый до сотых (`timer-store.ts:161-163`). `PomodoroConfig.pomodoroCompleted` — колонка `real`, накапливает дроби. +- `taskStore.incrementPomodoro` (`features/tasks/model/task-store.ts:197`) — **мёртвый код**, его никто не вызывает. Как следствие бейдж `X/Y` в списке не обновляется живьём, только после `fetchTasks()`. +- **Календарь уже умеет рисовать выполненное**: `features/calendar/lib/calendar-events.ts:24` берёт `completed: task.completed`, `CalendarEventBlock.vue:19` даёт зачёркивание + чекбокс. Отдельной работы по календарю не требуется — нужно лишь чтобы `task.completed` доехал до `task-store`. +- `pomodoroCount` сегодня используется только для отображения: таймер после N помидоров не останавливается, крутит фазы бесконечно. + +### Выбранный подход (вариант A) + +**Правило живёт на бэкенде**, потому что фронтовый `timer-store` вообще не знает накопленного `pomodoroCompleted` — у него только настройки сессии. Бэк — единственное место, где решение принимается по достоверным данным и переживает перезагрузку страницы / вторую вкладку. + +Backend, `TaskService.incrementPomodoro`: + +1. `findById` (эагерно тянет `pomodoroConfig`) → снимок `before` и `target`. +2. Атомарный `repo.incrementPomodoroCompleted(taskId, increment)` — как сейчас. +3. Перечитать задачу → авторитетный `after`. Перечитывание нужно в любом случае, чтобы вернуть клиенту свежий `pomodoroCompleted` для живого бейджа. +4. Чистый предикат `shouldAutoComplete({ before, after, target })` в новом `task/domain/pomodoro.utils.ts` (по образцу `recurrence.utils.ts` — домен, без зависимостей фреймворка). +5. Если предикат сработал — вызвать **существующий** `this.update(userId, taskId, { completed: true })`. Это тот же путь, что у ручной галочки: для повторяющихся задач автоматически отрабатывает `completeRecurringTask` (новый инстанс с `pomodoroCompleted = 0`, инкремент `recurringCompletedCount`). Второй ветки логики завершения не появляется. +6. Эндпоинт `PATCH /tasks/:id/pomodoro` начинает возвращать `UpdateTaskResponse` (`{ task, nextInstance? }`) — ту же форму, что и `PATCH /tasks/:id`. + +Frontend: + +- `task-store.incrementPomodoro` оживает: оптимистичный бамп (как сейчас), затем разбор ответа тем же кодом, что и в `updateTask` (общий хелпер применения `{ task, nextInstance?, deletedInstanceId? }` к стору вместо копипасты). При ошибке — откат бампа, как сейчас. +- `timer-store.incrementPomodoro` перестаёт ходить в API сам и делегирует в `taskStore.incrementPomodoro(taskId, fraction)`. Кросс-фичевого цикла импортов это не создаёт: `tasks/model/task-store.ts` из `task-timer` ничего не тянет (обратный импорт есть только на уровне `tasks/ui` → `task-timer`). +- Календарь не трогаем. + +### Решения и что их закрыло + +- **Порог — на накопленной дробной сумме**, а не на числе доведённых до конца помидоров. Это ровно то, что видит пользователь в бейдже `X/Y`, и не требует нового поля в БД. +- **Повторяющиеся задачи закрываются так же, как галочкой.** Альтернатива (не автозакрывать recurring) породила бы непоследовательное поведение, которое пришлось бы объяснять в UI. +- **Таймер после автозакрытия продолжает цикл фаз** — решение пользователя. Задача просто помечается выполненной; работать сверх плана не запрещаем. Правило перехода (ниже) гарантирует, что она не перезакроется. +- **Триггер по переходу, а не по факту «выше порога».** `before < target && after >= target`. Даёт бесплатный escape-hatch: снял галочку вручную → продолжай работать, задача сама не перезакроется. +- **Бэкфилла нет.** Задачи, уже стоящие на `4/4` и незакрытые, ретроспективно не закрываются. Ретроактивное закрытие чужих задач миграцией — сюрприз, которого не просили. + +### Известные следствия (принято) + +- Если уменьшить `pomodoroCount` с 8 до 2, будучи на `4.0`, — автозакрытия не будет: порог уже пройден, перехода нет. +- Инкремент и перечитывание не обёрнуты в транзакцию. При двух одновременно тикающих вкладках теоретически возможна гонка на определении перехода. Пользователь один, инкремент раз в ~25 минут — цена транзакции в SQLite того не стоит. Если понадобится — отдельная задача. + +### Обратная совместимость + +- `PATCH /tasks/:id/pomodoro` меняет пустое тело на `UpdateTaskResponse`. Аддитивно: единственный существующий потребитель (`timer-store`) тело игнорирует, внешних потребителей нет (грепом проверены `alfy-mcp` и модуль телеграма). +- Миграция схемы не нужна — переиспользуется существующее поле `Task.completed`. + +### Неизвестные + +- Есть ли на фронте существующие тесты `task-store` — уточняется на этапе Plan (`alfy-bot-frontend/tests/features/tasks/`). + +TDD: yes (детерминированный предикат порога с нетривиальными краями: дроби, переход, отсутствие конфига, `pomodoroCount = 0`, уже выполненная задача, `isOverdue`, повторяющиеся; есть куда встроиться — `task.service.spec.ts` уже содержит `describe('incrementPomodoro')`). + +### Invariants + +- Автозакрытие срабатывает **только на переходе через порог**: `before < target && after >= target`. Инкремент при уже пройденном пороге задачу не перезакрывает. +- Автозакрытие идёт исключительно через `TaskService.update(userId, id, { completed: true })`. Отдельной ветки завершения — в том числе для повторяющихся задач — не появляется; `completeRecurringTask` не дублируется. +- Правило не срабатывает, если выполнено любое из: нет `pomodoroConfig`; `pomodoroCount <= 0`; задача уже `completed`; `task.isOverdue`. +- Сравнение с порогом устойчиво к float-дребезгу (эпсилон при сравнении, накопление идёт через SQL `x = x + n` по `real`). +- Предикат порога — чистая функция в `alfy-bot/src/modules/task/domain/pomodoro.utils.ts`, без импортов NestJS/TypeORM. +- `PATCH /tasks/:id/pomodoro` отдаёт ту же форму ответа, что и `PATCH /tasks/:id` — `UpdateTaskResponse`. +- На фронте HTTP-вызов `/tasks/:id/pomodoro` существует ровно в одном месте — `task-store.incrementPomodoro`. `timer-store` в этот эндпоинт напрямую не ходит. +- Инкремент уходит на бэкенд даже если задачи нет в локальном `task-store` (например, `restoreSession` отработал раньше `fetchTasks`). Текущий guard `if (!task || !task.isPomodoroTask) return` не должен превратиться в тихую потерю инкремента. +- Календарь новой логики не получает: `CalendarEvent.completed` по-прежнему выводится из `task.completed`. +- `alfy-mcp` и модуль телеграм-бота в этой задаче не изменяются. + +### Principles + +- Инкремент — первичный эффект и не должен теряться из-за автозакрытия: путь автозакрытия защищён guard'ами так, чтобы не бросать после уже сохранённого инкремента. +- Fail loud, без тихих фолбэков: ошибку эндпоинта фронт откатывает и логирует, а не проглатывает. +- Переиспользовать существующие пути, а не добавлять параллельные: одна семантика завершения задачи, один разбор `UpdateTaskResponse` на фронте. +- YAGNI: ни нового поля в БД, ни миграции, ни изменений в поведении таймера. + +## Plan + +Approach: чистый предикат порога в `task/domain/`, вызывающий его `TaskService.incrementPomodoro` делегирует закрытие существующему `this.update(..., { completed: true })`; фронт получает `UpdateTaskResponse` и применяет его тем же кодом, что и обычный апдейт задачи. + +### Phase 1 — Backend: предикат порога + автозакрытие в сервисе + +- **1.1** `alfy-bot/src/modules/task/domain/pomodoro.utils.spec.ts` (create) — **пишется первым, красный** (TDD: yes) + - Покрытие `hasCrossedPomodoroTarget` — см. Test strategy. +- **1.2** `alfy-bot/src/modules/task/domain/pomodoro.utils.ts` (create) + - `export const POMODORO_EPSILON = 1e-6` + - `export function hasCrossedPomodoroTarget(before: number, after: number, target: number): boolean` — `target > 0 && before < target - POMODORO_EPSILON && after >= target - POMODORO_EPSILON` + - Invariant: триггер только на переходе; устойчивость к float-дребезгу; чистая функция без импортов NestJS/TypeORM (по образцу `recurrence.utils.ts`). +- **1.3** `alfy-bot/src/modules/task/task.service.ts:273-281` (modify) — `TaskService.incrementPomodoro` + - Сигнатура: `incrementPomodoro(userId: number, taskId: string, increment: number): Promise` (было `Promise`). + - Снять `before = task.pomodoroConfig?.pomodoroCompleted ?? 0` и `target = task.pomodoroConfig?.pomodoroCount ?? 0` до инкремента; `findById` эагерно тянет `pomodoroConfig`. + - После `taskRepo.incrementPomodoroCompleted` — повторный `findById` за авторитетным `after`; при `null` откатиться на исходный `task`. + - Автозакрытие при `!!task.pomodoroConfig && !task.completed && !task.isOverdue && hasCrossedPomodoroTarget(before, after, target)` → `return this.update(userId, taskId, { completed: true })`. + - Иначе `return { task: refreshed }`. + - Invariant: единственный путь завершения — `this.update`; `completeRecurringTask` не дублируется; guard'ы на `isOverdue` / отсутствие конфига / `pomodoroCount <= 0` / уже выполненную; путь автозакрытия не бросает после уже сохранённого инкремента. +- **1.4** `alfy-bot/src/modules/task/task.controller.ts:159-167` (modify) — `TaskController.incrementPomodoro` + - `return this.taskService.incrementPomodoro(...)` с типом `Promise` вместо `await` + пустого ответа. + - Invariant: та же форма ответа, что у `PATCH /tasks/:id`. +- **1.5** `alfy-bot/src/modules/task/task.service.spec.ts:289-318` (modify) — расширить `describe('incrementPomodoro')` +- **1.6** `alfy-bot/test/tasks.e2e-spec.ts` (modify) — блок `PATCH /api/tasks/:id/pomodoro` +- Commit: `feat(task): auto-complete task when pomodoro target is reached` + +### Phase 2 — Frontend: живой инкремент через task-store + +- **2.1** `alfy-bot-frontend/src/features/tasks/model/task-store.ts:95-164` (modify) — `updateTask` + - Извлечь строки 122-150 в приватный `applyUpdateResponse(response: Record, taskId: string): Task` — разбор `{ task, nextInstance?, deletedInstanceId? }` и патч стора (сохранение локального `checklist`, upsert `nextInstance`, удаление `deletedInstanceId`). `updateTask` начинает звать его. + - Invariant: один разбор `UpdateTaskResponse` на фронте, без копипасты. +- **2.2** `alfy-bot-frontend/src/features/tasks/model/task-store.ts:197-209` (modify) — `incrementPomodoro` + - Убрать guard `if (!task || !task.isPomodoroTask) return` — запрос уходит, даже если задачи нет в локальном сторе; оптимистичный бамп делается только когда задача найдена. + - Откат хранить как снятое значение `pomodoroCompleted`, не вычитанием (float-дрейф). + - Ответ прогонять через `applyUpdateResponse`; при ошибке — откат + `console.error`, как сейчас. + - Invariant: единственный HTTP-вызов `/tasks/:id/pomodoro` на фронте; инкремент не теряется тихо. +- **2.3** `alfy-bot-frontend/src/features/task-timer/model/timer-store.ts:157-172` (modify) — `incrementPomodoro` + - Убрать `api.patch`; вместо него `await useTaskStore().incrementPomodoro(taskId, fraction)`. `useTaskStore()` вызывается внутри функции (Pinia-идиома, без проблем с порядком активации). Guard `if (fraction <= 0) return` остаётся. `try/catch` уходит — обработка ошибки теперь в task-store. + - Импорт `import { useTaskStore } from '@/features/tasks/model/task-store'`. Цикла не возникает: `tasks/model/task-store.ts` тянет только `@/api/client`, `./types`, `../lib/dateTime`. + - Invariant: `timer-store` в `/tasks/:id/pomodoro` напрямую не ходит. +- **2.4** `alfy-bot-frontend/tests/features/tasks/model/task-store-pomodoro.spec.ts` (create) — по образцу `task-store-move.spec.ts` (`vi.mock('@/api/client')` + `setActivePinia`) +- **2.5** `alfy-bot-frontend/tests/features/task-timer/timer-store-pomodoro.spec.ts` (create) +- Commit: `feat(tasks): apply pomodoro increment response to task store` + +Календарь не изменяется — `CalendarEvent.completed` уже выводится из `task.completed`. + +### Test strategy + +TDD: yes — 1.1 пишется до 1.2, остальные тесты идут вместе со своей фазой. + +`pomodoro.utils.spec.ts`: +- `3.0 → 4.0`, target 4 → true (полный переход) +- `3.6 → 4.1`, target 4 → true (дробный переход) +- `3.0 → 3.6`, target 4 → false (не дошёл) +- `4.0 → 5.0`, target 4 → false (уже был на пороге — escape-hatch после ручного снятия галочки) +- `5.0 → 6.0`, target 4 → false (уже был выше) +- target `0` → false +- `3.9 → 3.9999999`, target 4 → true (float-дребезг снизу) +- `3.9999999 → 4.5`, target 4 → false (float-дребезг на пороге) + +`task.service.spec.ts` → `describe('incrementPomodoro')` (три существующих теста сохраняются): +- пересечение порога → `save` с `completed: true`, в ответе `task.completed === true` +- задача уже `completed` → повторно не закрывается +- `isOverdue` → не закрывается и не бросает +- нет `pomodoroConfig` → не закрывается +- порог не достигнут → `{ task }` со свежим `pomodoroCompleted`, `completed` не менялся +- recurring пересёк порог → `repo.create` вызван, `nextInstance` в ответе, у него `pomodoroCompleted === 0` + +`tasks.e2e-spec.ts` (реальный SQLite — покрывает и форму ответа, и накопление в БД): +- создать задачу с `pomodoroCount: 2` → два `PATCH .../pomodoro { increment: 1 }` → первый ответ `task.completed === false`, второй `true` +- третий инкремент по той же задаче не роняет запрос и не меняет `completed` + +`task-store-pomodoro.spec.ts`: +- оптимистичный бамп `pomodoroCompleted` до ответа +- `PATCH /tasks/task-1/pomodoro` с `{ increment }` +- ответ `{ task: { completed: true, pomodoroConfig: {...} } }` → в сторе `completed === true` и свежий `pomodoroCompleted` +- `nextInstance` в ответе → появляется в сторе +- ошибка API → `pomodoroCompleted` откатывается к исходному +- задачи нет в сторе → запрос всё равно отправлен (инвариант) + +`timer-store-pomodoro.spec.ts`: +- `stopTimeBlock()` на рабочей фазе → `taskStore.incrementPomodoro` вызван с `(taskId, fraction)` +- `api.patch` на `/tasks/:id/pomodoro` не вызывается +- фаза перерыва → инкремента нет + +### Order & dependencies + +Phase 1 → Phase 2: фронт-тесты мокают форму ответа, которую вводит Phase 1. Внутри Phase 2: 2.1 блокирует 2.2, 2.2 блокирует 2.3. + +### Backwards compatibility + +- `PATCH /tasks/:id/pomodoro`: пустое тело → `UpdateTaskResponse` (Phase 1.4). Аддитивно, потребитель один и он в этой же задаче переводится на новый ответ. Внешних потребителей нет — грепом проверены `alfy-mcp/src` и модуль телеграма. +- Миграции схемы нет; бэкфилла по решению Design нет. + +### Open questions / risks / rollback + +- `timer-store.incrementPomodoro` не входит в возвращаемый объект стора, поэтому тестируется только через `stopTimeBlock()`, который дёргает `useSounds` и `sendToSW`. Оба безопасны в happy-dom (`new Audio` в try/catch; `sendToSW` выходит по guard'у при отсутствии `navigator.serviceWorker`), но если тест окажется хрупким — экспортировать `incrementPomodoro` из стора. Не делать это превентивно. +- Инкремент и перечитывание не в транзакции — принято в Design; при двух тикающих вкладках возможна гонка на определении перехода. +- Rollback: обе фазы — отдельные коммиты, откатываются независимо; при откате только Phase 2 бэкенд продолжит закрывать задачи, фронт увидит это после `fetchTasks`. + +## Verify + +**Result:** passed + +Прогонялось в worktree `/Users/v/projects/Alfy-worktrees/auto-complete-pomodoros` на `9a7b4b6`. + +Positive: +- backend unit 349/349, e2e 31/31, frontend 51 файл / 321 тест, `vue-tsc` чисто +- предикат: полный переход, дробный переход, float-дребезг снизу +- сервис: пересечение порога закрывает задачу; recurring порождает `nextInstance` с `pomodoroCompleted = 0` +- фронт: ответ с `completed: true` применяется к стору; `nextInstance` попадает в стор + +Negative: +- порог не достигнут → счётчик свежий, `completed` не менялся +- уже выполненная / `isOverdue` / без `pomodoroConfig` / `target = 0` → не закрывается, не бросает +- ошибка API → оптимистичный бамп откатывается к снимку +- фаза перерыва → инкремент не пишется + +Invariants: +- `/tasks/:id/pomodoro` вызывается ровно из одного места на фронте — `task-store.ts:215` +- `pomodoro.utils.ts` не имеет ни одного импорта +- автозакрытие идёт только через `this.update(..., { completed: true })`, второй ветки завершения нет +- инкремент уходит на бэк и когда задачи нет в локальном сторе +- `features/calendar`, `alfy-mcp`, `modules/bot` в диффе обеих фаз отсутствуют + +Smoke: живой сервер на :3099 (отдельная БД), реальные curl — +`pomodoroCount=2`: +1 → `completed:false, done:1`; +1 → `completed:true, done:2`. +Escape-hatch: снять галочку → +1 → `completed:false, done:3` (не перезакрывается). +Дробное: `pomodoroCount=1`, +0.5 → открыта; +0.5 → `completed:true`. +Recurring daily: +1 → `completed:true` + `nextInstance` на следующий день, `pomodoroCompleted=0`, `recurringParentId` проставлен. +Без конфига: +5 → `completed:false`. + +Notes: +- В worktree нет untracked `.env`, поэтому e2e и smoke запускались с подставными `SMTP_*` / `BOT_TOKEN` / `JWT_SECRET` в командной строке. Копирование реального `.env` с секретами намеренно не делалось. +- Полный `npm run test:e2e` в worktree не завершается сам из-за открытых хендлов в teardown (тесты при этом проходят) — прогонялось с `--forceExit`. В основном дереве с настоящим `.env` завершается штатно, так что это свойство подставного окружения, а не регрессия. +- Фронтовый lint даёт 1913 проблем по всему репозиторию (стилевые правила `perfectionist`/`antfu`, нарушены и в нетронутых файлах вроде `vitest.config.ts`). По двум изменённым файлам: 82 → 81, то есть долга не добавлено. Приводить два файла к стандарту, который нарушают остальные 49, не стал. + +## Conclusion + +Outcome: помидоро-задача автозакрывается на переходе через `pomodoroCount`, и это доезжает до стора и календаря — `24e2d8c` (backend), `9a7b4b6` (frontend). + +Invariants: +- триггер только на переходе — предикат `hasCrossedPomodoroTarget`, 8 юнит-кейсов + живой smoke: после ручного снятия галочки третий помидор задачу не перезакрыл +- завершение только через `this.update(..., { completed: true })` — прочитано в диффе, второй ветки нет; smoke на recurring дал `nextInstance` со сброшенным счётчиком, то есть отработал существующий `completeRecurringTask` +- guard'ы `isOverdue` / нет конфига / `target <= 0` / уже выполненная — по тесту на каждый, плюс smoke на задаче без конфига +- устойчивость к float — кейсы с обеих сторон порога; smoke: `0.5 + 0.5` при цели 1 закрывает +- предикат без зависимостей фреймворка — в `pomodoro.utils.ts` ноль импортов +- один HTTP-вызов `/tasks/:id/pomodoro` на фронте — грепом по `src`, единственное вхождение `task-store.ts:215` +- инкремент не теряется, если задачи нет в сторе — отдельный тест на отправку запроса при пустом сторе +- календарь, `alfy-mcp`, `modules/bot` не изменялись — их нет в `git diff --name-only 5e4c4bb..HEAD` + +Plan adherence: два отклонения, оба зафиксированы ниже в `### Deviations from plan` — починка e2e-харнесса и трёх стухших ассертов. Оба вне первоначального плана, оба потребовались чтобы Phase 1.6 вообще была проверяема. + +Review findings: +- Minor: `previousCompleted` в `task-store.ts` означал булев `completed` (строка 176) и число `pomodoroCompleted` (строка 208) одновременно. Переименовано в `previousPomodoroCompleted`. +- Ревью инлайновое, без независимого `up:reviewer`: окружение сессии запрещает диспатч субагентов без явной просьбы пользователя. Независимость стадии слабее, чем предполагает скилл. + +Future work: +- `alfy-mcp/src/tools/tasks.ts:128,142` отдают клиенту `UpdateTaskResponse` целиком (`{task: {...}}`) вместо задачи. Найдено consistency-sweep'ом по тем же стухшим ассертам. Justification: инвариант задачи — «`alfy-mcp` и модуль телеграм-бота не изменяются». Пре-существует с задачи про recurring, влияние косметическое. +- У `PATCH /tasks/:id/pomodoro` нет DTO: `@Body() body: { increment: number }` — тип, а не класс, поэтому глобальный `ValidationPipe` его не проверяет и нечисловой либо отсутствующий `increment` уходит в TypeORM `increment()` без валидации. Justification: новый факт, всплывший на ревью; пре-существует, но диff повысил цену эндпоинта — он теперь переключает `completed`. Ложного автозакрытия не даёт (сравнение с `NaN` всегда false), но счётчик испортить может. + +Verified by: живой сервер на :3099 с отдельной БД и реальными curl — подробности в `## Verify`. Полный e2e в worktree требует `--forceExit` и подставных env-переменных, потому что untracked `.env` в worktree отсутствует. + +### Deviations from plan + +- Добавлены `alfy-bot/test/setup-e2e-env.ts` + `setupFiles` в `test/jest-e2e.json` — план их не предусматривал. Без них Phase 1.6 непроверяема: `app.module.ts` вычисляет `isTelegramEnabled()` во время импорта модуля, а `test/helpers/test-app.ts:29` выставляет `ENABLE_TELEGRAM=false` только внутри `createTestApp()`, то есть уже после импорта. Telegraf стартовал и валил каждый suite, использующий `createTestApp`, с `401: Bot Token is required`. Было 4 из 31 проходящих e2e, стало 31 из 31. +- Задача переехала в worktree `/Users/v/projects/Alfy-worktrees/auto-complete-pomodoros` между Phase 1 и Phase 2. Причина внешняя: в основном дереве `/Users/v/projects/Alfy` кто-то застэшил незакоммиченную Phase 2 (`stash@{0}`, «wip pomodoro frontend») и переключился на `feat/reschedule-recurring-this-vs-series`, где идёт параллельная работа. Ничего не потеряно — Phase 1 была в коммите `24e2d8c`, Phase 2 восстановлена из стэша в worktree. +- Починены три стухших ассерта в `tasks.e2e-spec.ts` (`PATCH /api/tasks/:id`): `body.title` / `body.completed` / `body.dueDate` → `body.task.*`. Эндпоинт возвращает `UpdateTaskResponse` со времён задачи про recurring; тесты просто никогда не доходили до ассертов из-за поломки выше. Этот эндпоинт задачей не затрагивался. + +### Hands-off decisions + + +### Deferred (needs user input) +