diff --git a/alfy-bot-frontend/src/features/task-timer/lib/index.ts b/alfy-bot-frontend/src/features/task-timer/lib/index.ts index 772aca7..b853f77 100644 --- a/alfy-bot-frontend/src/features/task-timer/lib/index.ts +++ b/alfy-bot-frontend/src/features/task-timer/lib/index.ts @@ -1 +1,2 @@ +export * from './task-timer-state' export * from './to-timer-task' diff --git a/alfy-bot-frontend/src/features/task-timer/lib/task-timer-state.ts b/alfy-bot-frontend/src/features/task-timer/lib/task-timer-state.ts new file mode 100644 index 0000000..8a685e0 --- /dev/null +++ b/alfy-bot-frontend/src/features/task-timer/lib/task-timer-state.ts @@ -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' +} 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..420d1d0 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 @@ -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, diff --git a/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts b/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts index 204ee40..6e0623b 100644 --- a/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts +++ b/alfy-bot-frontend/src/features/tasks/lib/active-tasks.ts @@ -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. @@ -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)), + } +} diff --git a/alfy-bot-frontend/src/features/tasks/lib/formatters.ts b/alfy-bot-frontend/src/features/tasks/lib/formatters.ts index 3dcd190..34bd7c9 100644 --- a/alfy-bot-frontend/src/features/tasks/lib/formatters.ts +++ b/alfy-bot-frontend/src/features/tasks/lib/formatters.ts @@ -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) diff --git a/alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue b/alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue index cac12bc..a113877 100644 --- a/alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue +++ b/alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue @@ -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(() => + 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)) +}