From fec8943fd0a187aebf07915a1e8b4aef3000f42d Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 13:41:12 +0500 Subject: [PATCH 1/2] feat(tasks): show remaining time and progress in the current-task widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget answered "what am I on" but not "for how long" — the thing you actually open it for — so you still had to go to the calendar. Each row now carries the remaining time, a progress bar, and the pomodoro count, with a left accent bar echoing the calendar's current-time line. The window maths moved into a private getWindow(), so the active-task filter and the progress readout are built on one definition and cannot disagree. Header is now "Идёт сейчас": the old "Текущая задача" was singular while the list can hold several rows. Rows carry a border rather than relying on bg-sidebar-accent/40 alone — in light mode that fill sat within 1.7% of the sidebar background (0.968 vs 0.985 oklch) and the rows were effectively invisible. The play button keeps its 32px visual size but expands to a 44px hit area via ::before, and the progress fill honours prefers-reduced-motion. --- .../src/features/tasks/lib/active-tasks.ts | 55 +++++++++++--- .../src/features/tasks/lib/formatters.ts | 12 +++ .../features/tasks/ui/CurrentTaskWidget.vue | 73 ++++++++++++++----- .../features/tasks/lib/active-tasks.spec.ts | 45 +++++++++++- .../tasks/lib/format-remaining.spec.ts | 20 +++++ .../tasks/ui/CurrentTaskWidget.spec.ts | 25 ++++++- 6 files changed, 201 insertions(+), 29 deletions(-) create mode 100644 alfy-bot-frontend/tests/features/tasks/lib/format-remaining.spec.ts 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..fc30fc4 100644 --- a/alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue +++ b/alfy-bot-frontend/src/features/tasks/ui/CurrentTaskWidget.vue @@ -5,38 +5,75 @@ 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 { 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 +} + const { tasks } = storeToRefs(useTaskStore()) const timerStore = useTimerStore() 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) })) + .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}` +}