Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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'

Expand DownExpand Up@@ -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 {
Expand Down
82 changes: 47 additions & 35 deletions alfy-bot-frontend/src/features/tasks/model/task-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,42 @@ export const useTaskStore = defineStore('tasks', () => {
const loading = ref(false)
const error = ref<string | null>(null)

/**
* Apply a backend UpdateTaskResponse — { task, nextInstance?, deletedInstanceId? } —
* to the store. Shared by every endpoint that returns that shape.
*/
const applyUpdateResponse = (response: Record<string, unknown>, taskId: string): Task => {
const taskData = response.task ? response.task as Record<string, unknown> : 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<string, unknown>)
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
Expand DownExpand Up@@ -119,37 +155,7 @@ export const useTaskStore = defineStore('tasks', () => {
} = updates as Record<string, unknown>
const { data } = await api.patch(`/tasks/${taskId}`, serializeTaskDates(rest))

// Backend returns UpdateTaskResponse: { task, nextInstance?, deletedInstanceId? }
const response = data as Record<string, unknown>
const taskData = response.task ? response.task as Record<string, unknown> : 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<string, unknown>)
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<string, unknown>, taskId)
} catch (err) {
if (setLoading) {
error.value = err instanceof Error ? err.message : 'Ошибка обновления задачи'
Expand DownExpand Up@@ -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<string, unknown>, taskId)
} catch (err) {
task.pomodoroCompleted = Math.round(((task.pomodoroCompleted || 0) - increment) * 100) / 100
if (task) task.pomodoroCompleted = previousPomodoroCompleted
console.error('Ошибка сохранения помодоро:', err)
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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()
})
})
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown> = {}) {
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<string, unknown>[] = [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 })
})
})
35 changes: 35 additions & 0 deletions alfy-bot/src/modules/task/domain/pomodoro.utils.spec.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions alfy-bot/src/modules/task/domain/pomodoro.utils.ts
Original file line numberDiff line numberDiff line change
@@ -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;
}
Loading
Loading