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
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
@@ -1 +1,2 @@
export * from './task-timer-state'
export * from './to-timer-task'
22 changes: 22 additions & 0 deletions alfy-bot-frontend/src/features/task-timer/lib/task-timer-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type TaskTimerState = 'idle' | 'running' | 'paused'

export interface TimerSnapshot {
/** Task the current session belongs to, null when no task is selected. */
activeTaskId: string | null
/** 0 means no session is armed. */
phase: number
/** Whether the session is ticking rather than paused. */
isActive: boolean
}

/**
* What the pomodoro timer is doing for one particular task.
*
* `idle` covers both "nothing runs" and "something else runs" — from the row's
* point of view those are the same: starting here would take the timer over.
*/
export function getTaskTimerState(taskId: string, timer: TimerSnapshot): TaskTimerState {
if (timer.activeTaskId !== taskId) return 'idle'
if (timer.phase === 0) return 'idle'
return timer.isActive ? 'running' : 'paused'
}
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,12 @@ export const useTimerStore = defineStore('timer', () => {

const isBreakPhase = computed(() => checkPhase.isBreakPhase(phase.value))

/** Which task the session belongs to — lets callers tell "running" from "running on something else". */
const activeTaskId = computed(() => currentSettings.value.taskId)

return {
isActive,
activeTaskId,
phase,
timeBlock,
namePhase,
Expand Down
55 changes: 46 additions & 9 deletions alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import type { Task } from '../model/types'
import { computeTaskDurationMinutes } from './duration'

export interface TaskProgress {
/** Share of the window already elapsed, clamped to 0..1. */
ratio: number
remainingMinutes: number
}

interface TaskWindow {
startMs: number
endMs: number
}

/**
* The task's scheduled window, or null when it has none.
*
* Single definition of "when does this task run" — both the active-task filter
* and the progress readout are built on it, so they can never disagree.
*/
function getWindow(task: Task): TaskWindow | null {
if (!task.dueDate) return null

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 null

const startMs = start.getTime()
return { startMs, endMs: startMs + computeTaskDurationMinutes(task) * 60_000 }
}

/**
* Tasks whose scheduled window contains `now` — what the calendar's red line
* crosses right now.
Expand All @@ -13,20 +42,28 @@ export function getActiveTasksAt(tasks: Task[], now: Date): Task[] {

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
const window = getWindow(task)
if (!window) return false

// End is exclusive so two back-to-back tasks never show up together.
return startMs <= nowMs && nowMs < endMs
return window.startMs <= nowMs && nowMs < window.endMs
})
.sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
}

/** How far into its window a task is, or null when it has no window. */
export function getTaskProgressAt(task: Task, now: Date): TaskProgress | null {
const window = getWindow(task)
if (!window) return null

const total = window.endMs - window.startMs
const elapsed = now.getTime() - window.startMs

return {
ratio: total > 0 ? Math.min(1, Math.max(0, elapsed / total)) : 0,
remainingMinutes: Math.max(0, Math.ceil((window.endMs - now.getTime()) / 60_000)),
}
}
12 changes: 12 additions & 0 deletions alfy-bot-frontend/src/features/tasks/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export function formatDueDate(value: unknown, opts?: { includeYear?: boolean }):
return formatDate(date, hasTime ? DATE_WITH_TIME : DATE_SHORT)
}

/** Compact "сколько осталось" for the sidebar: «45 мин», «3 ч 12 мин», «2 ч». */
export function formatRemaining(minutes: number): string {
if (minutes < 1) return 'меньше минуты'

const hours = Math.floor(minutes / 60)
const rest = minutes % 60

if (hours === 0) return `${rest} мин`
if (rest === 0) return `${hours} ч`
return `${hours} ч ${rest} мин`
}

export const formatPomodoro = (value: number): string => {
const rounded = Math.round(value * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
Expand Down
111 changes: 92 additions & 19 deletions alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,112 @@ 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 { getTaskTimerState, toTimerTask, useTimerStore, type TaskTimerState } from '@/features/task-timer'
import { getActiveTasksAt, getTaskProgressAt, type TaskProgress } from '../lib/active-tasks'
import { formatPomodoro, formatRemaining } from '../lib/formatters'
import type { Task } from '../model/types'
import { useTaskStore } from '../model/task-store'

interface ActiveRow {
task: Task
progress: TaskProgress
timerState: TaskTimerState
}

const { tasks } = storeToRefs(useTaskStore())
const timerStore = useTimerStore()
const { activeTaskId, phase, isActive } = storeToRefs(timerStore)
const now = useNow()

const activeTasks = computed(() => getActiveTasksAt(tasks.value, now.value))
const rows = computed<ActiveRow[]>(() =>
getActiveTasksAt(tasks.value, now.value)
.map(task => ({
task,
progress: getTaskProgressAt(task, now.value),
timerState: getTaskTimerState(task.id, {
activeTaskId: activeTaskId.value,
phase: phase.value,
isActive: isActive.value,
}),
}))
.filter((row): row is ActiveRow => row.progress !== null),
)

function pomodoroLabel(task: Task): string | null {
if (!task.isPomodoroTask) return null
return `${formatPomodoro(task.pomodoroCompleted || 0)}/${task.pomodoroCount || 0}`
}

/**
* Paused sessions resume; idle ones start fresh. startTask() rewinds to the
* first phase, so calling it on a live session would discard finished pomodoros.
*/
function handlePlay(row: ActiveRow): void {
if (row.timerState === 'paused') {
timerStore.toggleTimer()
return
}
timerStore.startTask(toTimerTask(row.task))
}
</script>

<template>
<div v-if="activeTasks.length" class="px-4 mt-4">
<div v-if="rows.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 class="flex flex-col gap-2 mt-1.5">
<div
v-for="task in activeTasks"
v-for="{ task, progress, timerState } in rows"
:key="task.id"
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-sidebar-accent/40"
class="relative overflow-hidden rounded-md border border-sidebar-border bg-sidebar-accent/60 pl-3 pr-2 py-2 transition-colors duration-200 hover:bg-sidebar-accent"
>
<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>
<!-- Живая полоса слева: эхо линии текущего времени в календаре -->
<span class="absolute left-0 inset-y-0 w-[3px] bg-primary" aria-hidden="true" />

<div class="flex items-center gap-1">
<span class="truncate flex-1 text-sm font-medium leading-tight">{{ task.title }}</span>

<span
v-if="timerState !== 'idle'"
class="shrink-0 flex items-center gap-1 text-[11px]"
:class="timerState === 'running' ? 'text-red-500' : 'text-sidebar-foreground/50'"
>
<span
class="size-1.5 rounded-full bg-current"
:class="timerState === 'running' && 'animate-pulse motion-reduce:animate-none'"
/>
{{ timerState === 'running' ? 'идёт' : 'пауза' }}
</span>

<!-- Пока сессия тикает, кнопки нет: перезапуск стёр бы отработанные помидоры -->
<Button
v-if="task.isPomodoroTask && timerState !== 'running'"
variant="ghost"
size="icon-sm"
class="relative shrink-0 text-red-500 hover:text-red-500 hover:bg-red-500/10 cursor-pointer before:absolute before:-inset-1.5 before:content-['']"
:aria-label="`${timerState === 'paused' ? 'Продолжить' : 'Запустить'} помодоро: ${task.title}`"
@click="handlePlay({ task, progress, timerState })"
>
<Play :size="14" />
</Button>
</div>

<div class="mt-0.5 text-[11px] text-sidebar-foreground/60 tabular-nums">
осталось {{ formatRemaining(progress.remainingMinutes) }}
<template v-if="pomodoroLabel(task)">
· {{ pomodoroLabel(task) }}
</template>
</div>

<!-- Прогресс дублирует текст выше, поэтому скрыт от скринридеров -->
<div class="mt-1.5 h-[3px] rounded-full bg-sidebar-foreground/15" aria-hidden="true">
<div
class="h-full rounded-full bg-primary transition-[width] duration-500 ease-linear motion-reduce:transition-none"
:style="{ width: `${Math.round(progress.ratio * 100)}%` }"
/>
</div>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { getTaskTimerState } from '@/features/task-timer/lib/task-timer-state'

const ticking = { activeTaskId: 'task-1', phase: 1, isActive: true }

describe('getTaskTimerState', () => {
it('таймер тикает по этой задаче → running', () => {
expect(getTaskTimerState('task-1', ticking)).toBe('running')
})

it('сессия есть, но не тикает → paused', () => {
expect(getTaskTimerState('task-1', { ...ticking, isActive: false })).toBe('paused')
})

it('таймер идёт по другой задаче → idle', () => {
expect(getTaskTimerState('task-2', ticking)).toBe('idle')
})

it('задача не выбрана → idle', () => {
expect(getTaskTimerState('task-1', { ...ticking, activeTaskId: null })).toBe('idle')
})

it('сессия не заведена (phase 0) → idle, даже если id совпал', () => {
expect(getTaskTimerState('task-1', { ...ticking, phase: 0 })).toBe('idle')
})
})
45 changes: 44 additions & 1 deletion alfy-bot-frontend/tests/features/tasks/lib/active-tasks.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Task } from '@/features/tasks/model/types'
import { describe, expect, it } from 'vitest'
import { getActiveTasksAt } from '@/features/tasks/lib/active-tasks'
import { getActiveTasksAt, getTaskProgressAt } from '@/features/tasks/lib/active-tasks'

function makeTask(overrides: Partial<Task> = {}): Task {
return {
Expand Down Expand Up @@ -87,3 +87,46 @@ describe('getActiveTasksAt', () => {
expect(result.map(t => t.id)).toEqual(['long', 'short'])
})
})

describe('getTaskProgressAt', () => {
it('на середине окна даёт ratio 0.5 и половину остатка', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getTaskProgressAt(task, at('2026-08-13T12:30:00'))).toEqual({
ratio: 0.5,
remainingMinutes: 30,
})
})

it('в момент старта ratio 0, остаток равен всей длительности', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getTaskProgressAt(task, at('2026-08-13T12:00:00'))).toEqual({
ratio: 0,
remainingMinutes: 60,
})
})

it('за пределами окна ratio зажимается в 0..1, остаток не уходит в минус', () => {
const task = plain('2026-08-13T12:00:00', 60)
expect(getTaskProgressAt(task, at('2026-08-13T14:00:00'))).toEqual({
ratio: 1,
remainingMinutes: 0,
})
expect(getTaskProgressAt(task, at('2026-08-13T11:00:00'))?.ratio).toBe(0)
})

it('учитывает перерывы помидоро: на 12:02 из окна 11:35–12:30 остаётся 28 минут', () => {
const task = makeTask({
dueDate: new Date('2026-08-13T11:35:00'),
isPomodoroTask: true,
pomodoroCount: 2,
pomodoroDuration: 25,
shortBreak: 5,
})
expect(getTaskProgressAt(task, at('2026-08-13T12:02:00'))?.remainingMinutes).toBe(28)
})

it('задача без окна даёт null', () => {
expect(getTaskProgressAt(makeTask(), at('2026-08-13T12:00:00'))).toBeNull()
expect(getTaskProgressAt(plain('2026-08-13T00:00:00', 60), at('2026-08-13T00:30:00'))).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { formatRemaining } from '@/features/tasks/lib/formatters'

describe('formatRemaining', () => {
it('меньше часа — только минуты', () => {
expect(formatRemaining(45)).toBe('45 мин')
})

it('ровные часы — без минут', () => {
expect(formatRemaining(120)).toBe('2 ч')
})

it('часы с минутами', () => {
expect(formatRemaining(192)).toBe('3 ч 12 мин')
})

it('меньше минуты не показывает ноль', () => {
expect(formatRemaining(0)).toBe('меньше минуты')
})
})
Loading
Loading