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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,18 @@ ESM-пакет, Node 22+. SDK — `@modelcontextprotocol/sdk` (`McpServer` + `St
- `views/` — страницы, подключённые в `router/index.ts`.
- `stores/` — глобальные Pinia-сторы (`user-store`, `question-types-store`).
- `api/` — общий HTTP-клиент: `api/client.ts` (axios инстанс с JWT-интерцептором и редиректом на `/login` по 401), `api/auth.ts`, `api/tokenStorage.ts`.
- `composables/` — реюзабельные composables (`useCooldown`, `usePushSubscription`, ...).
- `composables/` — реюзабельные composables (`useCooldown`, `usePushSubscription`, `useNow`, ...).
- `mocks/` — MSW-моки для тестов.
- `sw.ts` — кастомный service worker (workbox).

**Справочник типов вопросов — единый источник на бэке.** Лейблы/example/`options` типов вопросов (`text`/`rating`/`emoji_rating`/`yes_no`/`number`/`time_spent`/`photo`) живут ТОЛЬКО в `alfy-bot/src/shared/types/question-types.ts` (`QUESTION_TYPES`). Бот читает импортом, веб — по `GET /api/question-types` (отдельный `@Controller('question-types')`) через `stores/question-types-store.ts` (грузится в router guard на входе в авторизованную часть, идемпотентно). НЕ воссоздавать хардкод-копию на фронте — это рассинхронизирует бота и веб. answer-format-логика (emoji_rating→1-based индекс, yes_no→`yes`/`no`) — отдельно в `features/goals/lib/answer-format.ts`, это логика, не данные.

Авторизация: при старте `main.ts` пробует `authorize()` если есть `Telegram.WebApp.initData` или dev-флаг `VITE_DEV_TELEGRAM_ID`. Router guard в `router/index.ts` редиректит на `/login` любой не-public маршрут при отсутствии токена. Public-маршруты помечены `meta: { public: true }`.

**Секционный блок сайдбара — один компонент на секцию.** `sectionExtraRegistry` в `components/AppLayout.vue` отображает `meta.sectionNav` в **ровно один** компонент (`tasks` → `TasksSidebarSection.vue`). Чтобы добавить в сайдбар ещё один блок, композиция делается внутри секционного компонента, а не расширением реестра до массива. `AppSidebar.vue` рендерит слот `section-extra` **дважды** — в десктопном `<aside>` и в мобильной панели внутри `<Teleport>`; оба живут в DOM одновременно, поэтому секционный компонент монтируется два раза, и всё, что он заводит (интервалы, подписки), существует в двух экземплярах.

**`VITE_API_URL` задаётся вместе с префиксом `/api`.** `api/client.ts` подставляет переменную в `baseURL` как есть, а вызовы идут как `api.get('/tasks')` — значит для локального бэка нужно `http://localhost:3002/api`, иначе все запросы получают 404.

## Drag-and-drop (две системы — выбирать по контексту)

В кодбазе сосуществуют два независимых DnD-стека. Не смешивать.
Expand Down
6 changes: 3 additions & 3 deletions alfy-bot-frontend/src/components/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { habitsNavLinks } from '@/router/habits-nav'
import { tasksNavLinks } from '@/router/tasks-nav'
import AppSidebar from './AppSidebar.vue'

const ProjectTreeNav = defineAsyncComponent(() =>
import('@/features/projects/ui/ProjectTreeNav.vue'),
const TasksSidebarSection = defineAsyncComponent(() =>
import('./TasksSidebarSection.vue'),
)

const route = useRoute()
Expand All @@ -22,7 +22,7 @@ const sectionNavRegistry: Record<string, NavLink[]> = {
}

const sectionExtraRegistry: Record<string, Component> = {
tasks: ProjectTreeNav,
tasks: TasksSidebarSection,
}

const sectionLinks = computed<NavLink[] | undefined>(() => {
Expand Down
9 changes: 9 additions & 0 deletions alfy-bot-frontend/src/components/TasksSidebarSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script setup lang="ts">
import CurrentTaskWidget from '@/features/tasks/ui/CurrentTaskWidget.vue'
import ProjectTreeNav from '@/features/projects/ui/ProjectTreeNav.vue'
</script>

<template>
<CurrentTaskWidget />
<ProjectTreeNav />
</template>
25 changes: 25 additions & 0 deletions alfy-bot-frontend/src/composables/useNow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'

/**
* Current time, refreshed on an interval.
*
* Defaults to a minute because that is the resolution the calendar's red line
* already uses — anything finer would re-render for no visible gain.
*/
export function useNow(intervalMs = 60_000): Ref<Date> {
const now = ref(new Date())
let timer: ReturnType<typeof setInterval> | null = null

onMounted(() => {
timer = setInterval(() => {
now.value = new Date()
}, intervalMs)
})

onUnmounted(() => {
if (timer) clearInterval(timer)
})

return now
}
1 change: 1 addition & 0 deletions alfy-bot-frontend/src/features/task-timer/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './ui'
export * from './model'
export * from './lib'
1 change: 1 addition & 0 deletions alfy-bot-frontend/src/features/task-timer/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './to-timer-task'
21 changes: 21 additions & 0 deletions alfy-bot-frontend/src/features/task-timer/lib/to-timer-task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Task as TaskEntity } from '@/features/tasks/model/types'
import type { Task as TimerTask } from '../types'
import { POMODORO_DEFAULTS } from '@/features/tasks/model/constants'

/**
* Map a task onto the shape `timerStore.startTask` expects.
*
* Uses `||` rather than `??` on purpose: this preserves the behaviour of the
* call sites this helper replaced, where a stored 0 collapses to the default
* instead of starting a zero-length pomodoro.
*/
export function toTimerTask(task: TaskEntity): TimerTask {
return {
id: task.id,
pomodoroTime: task.pomodoroDuration || POMODORO_DEFAULTS.duration,
breakTime: task.shortBreak || POMODORO_DEFAULTS.shortBreak,
longBreakTime: task.longBreak || POMODORO_DEFAULTS.longBreak,
longBreakInterval: task.longBreakInterval || POMODORO_DEFAULTS.longBreakInterval,
pomodoroCount: task.pomodoroCount || POMODORO_DEFAULTS.count,
}
}
32 changes: 32 additions & 0 deletions alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Task } from '../model/types'
import { computeTaskDurationMinutes } from './duration'

/**
* Tasks whose scheduled window contains `now` — what the calendar's red line
* crosses right now.
*
* `now` is an argument rather than read from the clock so the rule stays pure
* and testable without faking timers.
*/
export function getActiveTasksAt(tasks: Task[], now: Date): Task[] {
const nowMs = now.getTime()

return tasks
.filter((task) => {
if (!task.dueDate) return false
if (task.completed) return false
if (task.isOverdue) return false

const start = new Date(task.dueDate)
// A task pinned to exactly 00:00 is all-day, same rule the calendar uses
// in calendar-events.ts — it has no meaningful window.
if (start.getHours() === 0 && start.getMinutes() === 0) return false

const startMs = start.getTime()
const endMs = startMs + computeTaskDurationMinutes(task) * 60_000

// End is exclusive so two back-to-back tasks never show up together.
return startMs <= nowMs && nowMs < endMs
})
.sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
}
43 changes: 43 additions & 0 deletions alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { Play } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useNow } from '@/composables/useNow'
import { toTimerTask, useTimerStore } from '@/features/task-timer'
import { getActiveTasksAt } from '../lib/active-tasks'
import { useTaskStore } from '../model/task-store'

const { tasks } = storeToRefs(useTaskStore())
const timerStore = useTimerStore()
const now = useNow()

const activeTasks = computed(() => getActiveTasksAt(tasks.value, now.value))
</script>

<template>
<div v-if="activeTasks.length" class="px-4 mt-4">
<span class="text-[11px] font-medium text-sidebar-foreground/60 uppercase tracking-wide">
Текущая задача
</span>
<div class="flex flex-col gap-0.5 mt-1">
<div
v-for="task in activeTasks"
:key="task.id"
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-sidebar-accent/40"
>
<span class="truncate flex-1">{{ task.title }}</span>
<Button
v-if="task.isPomodoroTask"
variant="ghost"
size="icon-sm"
class="shrink-0"
:aria-label="`Запустить помодоро: ${task.title}`"
@click="timerStore.startTask(toTimerTask(task))"
>
<Play :size="14" class="text-red-500" />
</Button>
</div>
</div>
</div>
</template>
11 changes: 2 additions & 9 deletions alfy-bot-frontend/src/features/tasks/ui/TaskDetailDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ import { PRIORITY_LABELS, POMODORO_DEFAULTS } from '../model/constants'
import { getPriorityColor } from '../lib/priority'
import { type DueDateUrgency, getDueDateUrgency } from '../lib/urgency'
import { createChecklistItem, computeChecklistProgress } from '../lib/checklist'
import { useTimerStore } from '@/features/task-timer'
import { toTimerTask, useTimerStore } from '@/features/task-timer'

const URGENCY_CLASSES: Record<DueDateUrgency, string> = {
overdue: 'text-red-500',
Expand Down Expand Up @@ -676,14 +676,7 @@ const timerStore = useTimerStore()

function handleStartTimer() {
if (!props.task?.isPomodoroTask) return
timerStore.startTask({
id: props.task.id,
pomodoroTime: props.task.pomodoroDuration || 25,
breakTime: props.task.shortBreak || 5,
longBreakTime: props.task.longBreak || 15,
longBreakInterval: props.task.longBreakInterval || 4,
pomodoroCount: props.task.pomodoroCount || 4,
})
timerStore.startTask(toTimerTask(props.task))
}

// Project
Expand Down
11 changes: 2 additions & 9 deletions alfy-bot-frontend/src/views/TasksView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import AppHeader from '@/components/AppHeader.vue'
import PageContainer from '@/components/PageContainer.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useProjectStore } from '@/features/projects/model/project-store'
import { useTimerStore } from '@/features/task-timer'
import { toTimerTask, useTimerStore } from '@/features/task-timer'
import { useReorderList } from '@/features/tasks/lib/dnd/use-reorder-list'
import { useTaskDnd } from '@/features/tasks/lib/dnd/use-task-dnd'
import { useHideOverdue } from '@/features/tasks/lib/use-hide-overdue'
Expand Down Expand Up @@ -115,14 +115,7 @@ function handleShowTimer(taskId: string) {
if (!task?.isPomodoroTask)
return

startTask({
id: task.id,
pomodoroTime: task.pomodoroDuration || 25,
breakTime: task.shortBreak || 5,
longBreakTime: task.longBreak || 15,
longBreakInterval: task.longBreakInterval || 4,
pomodoroCount: task.pomodoroCount || 4,
})
startTask(toTimerTask(task))
}

async function handleDeleteTask(taskId: string) {
Expand Down
70 changes: 70 additions & 0 deletions alfy-bot-frontend/tests/features/task-timer/to-timer-task.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Task } from '@/features/tasks/model/types'
import { describe, expect, it } from 'vitest'
import { toTimerTask } from '@/features/task-timer/lib/to-timer-task'

function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: 'Задача',
completed: false,
isPomodoroTask: true,
...overrides,
} as Task
}

describe('toTimerTask', () => {
it('переносит заполненные настройки один в один', () => {
const task = makeTask({
pomodoroDuration: 50,
shortBreak: 10,
longBreak: 30,
longBreakInterval: 2,
pomodoroCount: 8,
})

expect(toTimerTask(task)).toEqual({
id: 'task-1',
pomodoroTime: 50,
breakTime: 10,
longBreakTime: 30,
longBreakInterval: 2,
pomodoroCount: 8,
})
})

it('подставляет дефолты, когда настроек нет', () => {
expect(toTimerTask(makeTask())).toEqual({
id: 'task-1',
pomodoroTime: 25,
breakTime: 5,
longBreakTime: 15,
longBreakInterval: 4,
pomodoroCount: 4,
})
})

// Регрессия: исходные вызовы использовали ||, а не ??. Ноль обязан
// схлопываться в дефолт — подмена оператора на ?? сломает этот тест.
it('нулевые значения схлопываются в дефолты, а не остаются нулями', () => {
const task = makeTask({
pomodoroDuration: 0,
shortBreak: 0,
longBreak: 0,
longBreakInterval: 0,
pomodoroCount: 0,
})

expect(toTimerTask(task)).toEqual({
id: 'task-1',
pomodoroTime: 25,
breakTime: 5,
longBreakTime: 15,
longBreakInterval: 4,
pomodoroCount: 4,
})
})

it('переносит id без изменений', () => {
expect(toTimerTask(makeTask({ id: 'другой-id' })).id).toBe('другой-id')
})
})
89 changes: 89 additions & 0 deletions alfy-bot-frontend/tests/features/tasks/lib/active-tasks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Task } from '@/features/tasks/model/types'
import { describe, expect, it } from 'vitest'
import { getActiveTasksAt } from '@/features/tasks/lib/active-tasks'

function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: 'Задача',
completed: false,
...overrides,
} as Task
}

/** Обычная задача без помидоров: окно = durationMinutes, дефолт 60. */
function plain(start: string, durationMinutes?: number, overrides: Partial<Task> = {}): Task {
return makeTask({ dueDate: new Date(start), durationMinutes, ...overrides })
}

const at = (iso: string) => new Date(iso)

describe('getActiveTasksAt', () => {
it('now ровно на начале — задача активна', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getActiveTasksAt([task], at('2026-08-13T12:00:00'))).toEqual([task])
})

it('now за минуту до конца — задача активна', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getActiveTasksAt([task], at('2026-08-13T12:59:00'))).toEqual([task])
})

it('now ровно на конце — задача НЕ активна', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getActiveTasksAt([task], at('2026-08-13T13:00:00'))).toEqual([])
})

it('now до начала — задача не активна', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getActiveTasksAt([task], at('2026-08-13T11:59:00'))).toEqual([])
})

it('задача без dueDate не активна', () => {
const task = makeTask({ durationMinutes: 60 })
expect(getActiveTasksAt([task], at('2026-08-13T12:00:00'))).toEqual([])
})

it('all-day задача (время ровно 00:00) не активна', () => {
const task = plain('2026-08-13T00:00:00', 60)
expect(getActiveTasksAt([task], at('2026-08-13T00:30:00'))).toEqual([])
})

it('выполненная задача внутри окна не активна', () => {
const task = plain('2026-08-13T12:00:00', 60, { completed: true })
expect(getActiveTasksAt([task], at('2026-08-13T12:30:00'))).toEqual([])
})

it('overdue задача внутри окна не активна', () => {
const task = plain('2026-08-13T12:00:00', 60, { isOverdue: true })
expect(getActiveTasksAt([task], at('2026-08-13T12:30:00'))).toEqual([])
})

it('не-помидоро задача без durationMinutes получает окно в 60 минут', () => {
const task = plain('2026-08-13T12:00:00')
expect(getActiveTasksAt([task], at('2026-08-13T12:59:00'))).toEqual([task])
expect(getActiveTasksAt([task], at('2026-08-13T13:00:00'))).toEqual([])
})

it('окно помидоро-задачи включает перерывы: 2x25 + 5 = 55 минут', () => {
// Соответствует «Гитаре» 11:35–12:30 из макета.
const task = makeTask({
dueDate: new Date('2026-08-13T11:35:00'),
isPomodoroTask: true,
pomodoroCount: 2,
pomodoroDuration: 25,
shortBreak: 5,
longBreak: 15,
longBreakInterval: 4,
})
expect(getActiveTasksAt([task], at('2026-08-13T12:29:00'))).toEqual([task])
expect(getActiveTasksAt([task], at('2026-08-13T12:30:00'))).toEqual([])
})

it('две пересекающиеся активные задачи возвращаются обе, по возрастанию начала', () => {
const long = plain('2026-08-13T15:45:00', 230, { id: 'long' })
const short = plain('2026-08-13T16:00:00', 30, { id: 'short' })
const result = getActiveTasksAt([short, long], at('2026-08-13T16:10:00'))
expect(result.map(t => t.id)).toEqual(['long', 'short'])
})
})
Loading
Loading