From 20f2a26e48ea9e2b42a63aca45103172301fde0f Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 13:04:19 +0500 Subject: [PATCH 1/6] feat(tasks): add active-task window and timer-task mapping helpers getActiveTasksAt answers "which tasks does the calendar's red line cross right now". `now` is an argument rather than read from the clock, so the rule is pure and testable without faking timers. The end bound is exclusive so two back-to-back tasks never appear together. toTimerTask centralises the task -> startTask mapping that was copy-pasted in TasksView and TaskDetailDialog. It keeps `||` rather than `??` on purpose: the call sites it replaces collapse a stored 0 into the default, and a silent switch to `??` would start a zero-length pomodoro. A test pins that. --- .../src/features/task-timer/index.ts | 1 + .../src/features/task-timer/lib/index.ts | 1 + .../features/task-timer/lib/to-timer-task.ts | 21 ++ .../src/features/tasks/lib/active-tasks.ts | 32 +++ .../features/task-timer/to-timer-task.spec.ts | 70 +++++++ .../features/tasks/lib/active-tasks.spec.ts | 89 +++++++++ docs/tasks/sidebar-current-task-widget.md | 183 ++++++++++++++++++ 7 files changed, 397 insertions(+) create mode 100644 alfy-bot-frontend/src/features/task-timer/lib/index.ts create mode 100644 alfy-bot-frontend/src/features/task-timer/lib/to-timer-task.ts create mode 100644 alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts create mode 100644 alfy-bot-frontend/tests/features/task-timer/to-timer-task.spec.ts create mode 100644 alfy-bot-frontend/tests/features/tasks/lib/active-tasks.spec.ts create mode 100644 docs/tasks/sidebar-current-task-widget.md diff --git a/alfy-bot-frontend/src/features/task-timer/index.ts b/alfy-bot-frontend/src/features/task-timer/index.ts index 1ccc9c5..ec9d6a8 100644 --- a/alfy-bot-frontend/src/features/task-timer/index.ts +++ b/alfy-bot-frontend/src/features/task-timer/index.ts @@ -1,2 +1,3 @@ export * from './ui' export * from './model' +export * from './lib' diff --git a/alfy-bot-frontend/src/features/task-timer/lib/index.ts b/alfy-bot-frontend/src/features/task-timer/lib/index.ts new file mode 100644 index 0000000..772aca7 --- /dev/null +++ b/alfy-bot-frontend/src/features/task-timer/lib/index.ts @@ -0,0 +1 @@ +export * from './to-timer-task' diff --git a/alfy-bot-frontend/src/features/task-timer/lib/to-timer-task.ts b/alfy-bot-frontend/src/features/task-timer/lib/to-timer-task.ts new file mode 100644 index 0000000..5306fb1 --- /dev/null +++ b/alfy-bot-frontend/src/features/task-timer/lib/to-timer-task.ts @@ -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, + } +} diff --git a/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts b/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts new file mode 100644 index 0000000..204ee40 --- /dev/null +++ b/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts @@ -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()) +} diff --git a/alfy-bot-frontend/tests/features/task-timer/to-timer-task.spec.ts b/alfy-bot-frontend/tests/features/task-timer/to-timer-task.spec.ts new file mode 100644 index 0000000..f480d46 --- /dev/null +++ b/alfy-bot-frontend/tests/features/task-timer/to-timer-task.spec.ts @@ -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 { + 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') + }) +}) diff --git a/alfy-bot-frontend/tests/features/tasks/lib/active-tasks.spec.ts b/alfy-bot-frontend/tests/features/tasks/lib/active-tasks.spec.ts new file mode 100644 index 0000000..f298232 --- /dev/null +++ b/alfy-bot-frontend/tests/features/tasks/lib/active-tasks.spec.ts @@ -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 { + return { + id: 'task-1', + title: 'Задача', + completed: false, + ...overrides, + } as Task +} + +/** Обычная задача без помидоров: окно = durationMinutes, дефолт 60. */ +function plain(start: string, durationMinutes?: number, overrides: Partial = {}): 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']) + }) +}) diff --git a/docs/tasks/sidebar-current-task-widget.md b/docs/tasks/sidebar-current-task-widget.md new file mode 100644 index 0000000..1d950cd --- /dev/null +++ b/docs/tasks/sidebar-current-task-widget.md @@ -0,0 +1,183 @@ +# Виджет «Текущая задача» в сайдбаре + +**Status:** executing +**Branch:** feat/sidebar-current-task-widget +**Worktree:** /Users/v/projects/Alfy-worktrees/sidebar-current-task-widget +**Mode:** interactive + +## Design + +### Цель + +Блок «Текущая задача» в сайдбаре секции задач: показывает задачи, чьё временное окно содержит текущий момент, и для помидорных даёт кнопку запуска таймера. Отвечает на вопрос «чем я сейчас занят» без перехода в календарь. + +### Что уже есть (разведка) + +- `sectionExtraRegistry` в `components/AppLayout.vue:24-26` — реестр «секция → **один** компонент», сейчас `tasks` → `ProjectTreeNav` (подключён через `defineAsyncComponent`, строки 11-13). `AppSidebar.vue` пробрасывает результат в **два** слота `section-extra`: десктопный `