From 24e2d8c550aedb44943696724c7210502c8a334d Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 12:08:46 +0500 Subject: [PATCH 1/5] feat(task): auto-complete task when pomodoro target is reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /tasks/:id/pomodoro now decides completion on the backend: it snapshots the counter, applies the atomic increment, re-reads the authoritative value and routes through the existing update({ completed: true }) when the task crossed its pomodoroCount. Recurring tasks therefore behave exactly like a manual checkbox — no second completion path. The trigger is a transition (before < target <= after), not "counter is above target", so unchecking an auto-completed task lets the user keep working on it without it re-closing. The endpoint now returns UpdateTaskResponse, the same shape as PATCH /tasks/:id. Also fixes the e2e harness: AppModule resolves isTelegramEnabled() at import time, so createTestApp() set ENABLE_TELEGRAM too late and telegraf launched for real, failing every suite with "401: Bot Token is required" (4/31 passing). Moved the env setup into jest setupFiles and corrected three stale assertions that read PATCH /tasks/:id as a flat task. Now 31/31. --- .../task/domain/pomodoro.utils.spec.ts | 35 +++ .../src/modules/task/domain/pomodoro.utils.ts | 23 ++ alfy-bot/src/modules/task/task.controller.ts | 9 +- .../src/modules/task/task.service.spec.ts | 98 +++++++++ alfy-bot/src/modules/task/task.service.ts | 26 ++- alfy-bot/test/jest-e2e.json | 1 + alfy-bot/test/setup-e2e-env.ts | 7 + alfy-bot/test/tasks.e2e-spec.ts | 56 ++++- .../auto-complete-task-on-pomodoros-done.md | 200 ++++++++++++++++++ 9 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 alfy-bot/src/modules/task/domain/pomodoro.utils.spec.ts create mode 100644 alfy-bot/src/modules/task/domain/pomodoro.utils.ts create mode 100644 alfy-bot/test/setup-e2e-env.ts create mode 100644 docs/tasks/auto-complete-task-on-pomodoros-done.md 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..3628809 --- /dev/null +++ b/docs/tasks/auto-complete-task-on-pomodoros-done.md @@ -0,0 +1,200 @@ +# Автозакрытие задачи при выполнении всех помидоров + +**Status:** executing +**Branch:** feat/auto-complete-task-on-pomodoros-done +**Worktree:** none (работаем в /Users/v/projects/Alfy) +**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 + + +## Conclusion + + +### 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. +- Починены три стухших ассерта в `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) + From 9a7b4b6bbf381abc58093c01260fd56a10bf7c84 Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 12:19:20 +0500 Subject: [PATCH 2/5] feat(tasks): apply pomodoro increment response to task store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timer used to PATCH /tasks/:id/pomodoro itself and drop the response, so the X/Y badge never moved until a refetch and taskStore.incrementPomodoro was dead code. It now delegates to the store, which applies the backend response — including the auto-completion and any recurring nextInstance the backend produced when the pomodoro target was reached. Extracts applyUpdateResponse from updateTask so both callers share one parser for { task, nextInstance?, deletedInstanceId? } instead of duplicating it. Drops the `!task || !task.isPomodoroTask` guard: the timer can restore its session before fetchTasks resolves, and swallowing the increment because the task is not in the store yet would lose it. Only the optimistic bump is now conditional, and the rollback restores the snapshot instead of subtracting. --- .../features/task-timer/model/timer-store.ts | 9 +- .../src/features/tasks/model/task-store.ts | 82 ++++++------ .../task-timer/timer-store-pomodoro.spec.ts | 78 ++++++++++++ .../tasks/model/task-store-pomodoro.spec.ts | 118 ++++++++++++++++++ .../auto-complete-task-on-pomodoros-done.md | 3 +- 5 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 alfy-bot-frontend/tests/features/task-timer/timer-store-pomodoro.spec.ts create mode 100644 alfy-bot-frontend/tests/features/tasks/model/task-store-pomodoro.spec.ts 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..fa57419 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 previousCompleted = task?.pomodoroCompleted ?? 0 - task.pomodoroCompleted = Math.round(((task.pomodoroCompleted || 0) + increment) * 100) / 100 + if (task) { + task.pomodoroCompleted = Math.round((previousCompleted + 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 = previousCompleted 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/docs/tasks/auto-complete-task-on-pomodoros-done.md b/docs/tasks/auto-complete-task-on-pomodoros-done.md index 3628809..fd015d9 100644 --- a/docs/tasks/auto-complete-task-on-pomodoros-done.md +++ b/docs/tasks/auto-complete-task-on-pomodoros-done.md @@ -2,7 +2,7 @@ **Status:** executing **Branch:** feat/auto-complete-task-on-pomodoros-done -**Worktree:** none (работаем в /Users/v/projects/Alfy) +**Worktree:** /Users/v/projects/Alfy-worktrees/auto-complete-pomodoros **Mode:** interactive ## Design @@ -191,6 +191,7 @@ Phase 1 → Phase 2: фронт-тесты мокают форму ответа, ### 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 From c97defd9bb60e2a44e7947bcb98c21fe9a96a4b7 Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 12:32:50 +0500 Subject: [PATCH 3/5] docs(task): record verify results for pomodoro auto-complete --- .../auto-complete-task-on-pomodoros-done.md | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/tasks/auto-complete-task-on-pomodoros-done.md b/docs/tasks/auto-complete-task-on-pomodoros-done.md index fd015d9..d5c37c6 100644 --- a/docs/tasks/auto-complete-task-on-pomodoros-done.md +++ b/docs/tasks/auto-complete-task-on-pomodoros-done.md @@ -1,6 +1,6 @@ # Автозакрытие задачи при выполнении всех помидоров -**Status:** executing +**Status:** reviewing **Branch:** feat/auto-complete-task-on-pomodoros-done **Worktree:** /Users/v/projects/Alfy-worktrees/auto-complete-pomodoros **Mode:** interactive @@ -183,7 +183,41 @@ Phase 1 → Phase 2: фронт-тесты мокают форму ответа, - 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 From 799a7d577663b2b457e1fde9e3c32180715a4cdd Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 12:36:02 +0500 Subject: [PATCH 4/5] review(task): rename previousPomodoroCompleted, record conclusion --- .../src/features/tasks/model/task-store.ts | 6 ++--- .../auto-complete-task-on-pomodoros-done.md | 27 +++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) 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 fa57419..4624aa7 100644 --- a/alfy-bot-frontend/src/features/tasks/model/task-store.ts +++ b/alfy-bot-frontend/src/features/tasks/model/task-store.ts @@ -205,17 +205,17 @@ export const useTaskStore = defineStore('tasks', () => { // 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) - const previousCompleted = task?.pomodoroCompleted ?? 0 + const previousPomodoroCompleted = task?.pomodoroCompleted ?? 0 if (task) { - task.pomodoroCompleted = Math.round((previousCompleted + increment) * 100) / 100 + task.pomodoroCompleted = Math.round((previousPomodoroCompleted + increment) * 100) / 100 } try { const { data } = await api.patch(`/tasks/${taskId}/pomodoro`, { increment }) return applyUpdateResponse(data as Record, taskId) } catch (err) { - if (task) task.pomodoroCompleted = previousCompleted + if (task) task.pomodoroCompleted = previousPomodoroCompleted console.error('Ошибка сохранения помодоро:', err) } } diff --git a/docs/tasks/auto-complete-task-on-pomodoros-done.md b/docs/tasks/auto-complete-task-on-pomodoros-done.md index d5c37c6..521a80f 100644 --- a/docs/tasks/auto-complete-task-on-pomodoros-done.md +++ b/docs/tasks/auto-complete-task-on-pomodoros-done.md @@ -1,6 +1,6 @@ # Автозакрытие задачи при выполнении всех помидоров -**Status:** reviewing +**Status:** done **Branch:** feat/auto-complete-task-on-pomodoros-done **Worktree:** /Users/v/projects/Alfy-worktrees/auto-complete-pomodoros **Mode:** interactive @@ -220,7 +220,30 @@ Notes: - Фронтовый 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 From 9d5a8fbb6a7e54bc60152c7c61a7814721b769ce Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 12:36:36 +0500 Subject: [PATCH 5/5] docs: note e2e env setup order and UpdateTaskResponse-shaped endpoints --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) 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: