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
16 changes: 16 additions & 0 deletions alfy-bot-frontend/src/features/tasks/lib/duration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { countFromDurationMinutes, computePomodoroTotalMinutes } from './duration'

describe('countFromDurationMinutes', () => {
it('без duration → 4 раунда (дефолт)', () => {
expect(countFromDurationMinutes(computePomodoroTotalMinutes(4))).toBe(4)
})

it('60 минут → 2×25 (55 ближе, чем 1×25 или 3×25)', () => {
expect(countFromDurationMinutes(60)).toBe(2)
})

it('25 минут → 1 раунд', () => {
expect(countFromDurationMinutes(25)).toBe(1)
})
})
42 changes: 34 additions & 8 deletions alfy-bot-frontend/src/features/tasks/lib/duration.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
import type { Task } from '../model/types'
import { POMODORO_DEFAULTS } from '../model/constants'

export function computeTaskDurationMinutes(task: Task): number {
if (!task.isPomodoroTask) return task.durationMinutes ?? 60

const count = task.pomodoroCount ?? POMODORO_DEFAULTS.count
const duration = task.pomodoroDuration ?? POMODORO_DEFAULTS.duration
const shortBreak = task.shortBreak ?? POMODORO_DEFAULTS.shortBreak
const longBreak = task.longBreak ?? POMODORO_DEFAULTS.longBreak
const interval = task.longBreakInterval ?? POMODORO_DEFAULTS.longBreakInterval
const MAX_POMODORO_COUNT = 10

export function computePomodoroTotalMinutes(
count: number,
duration = POMODORO_DEFAULTS.duration,
shortBreak = POMODORO_DEFAULTS.shortBreak,
longBreak = POMODORO_DEFAULTS.longBreak,
interval = POMODORO_DEFAULTS.longBreakInterval,
): number {
let total = count * duration
for (let i = 1; i < count; i++) {
total += (i % interval === 0) ? longBreak : shortBreak
}
return total
}

export function computeTaskDurationMinutes(task: Task): number {
if (!task.isPomodoroTask)
return task.durationMinutes ?? 60

return computePomodoroTotalMinutes(
task.pomodoroCount ?? POMODORO_DEFAULTS.count,
task.pomodoroDuration ?? POMODORO_DEFAULTS.duration,
task.shortBreak ?? POMODORO_DEFAULTS.shortBreak,
task.longBreak ?? POMODORO_DEFAULTS.longBreak,
task.longBreakInterval ?? POMODORO_DEFAULTS.longBreakInterval,
)
}

export function countFromDurationMinutes(total: number): number {
let best = 1
let bestDiff = Infinity
for (let count = 1; count <= MAX_POMODORO_COUNT; count++) {
const diff = Math.abs(computePomodoroTotalMinutes(count) - total)
if (diff < bestDiff) {
bestDiff = diff
best = count
}
}
return best
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,20 +143,24 @@ export function useTaskDetailHandlers(store: TaskStore, confirm: ConfirmFn) {
}
}

async function handleUpdatePomodoroConfig(taskId: string, config: Record<string, unknown>) {
async function handleUpdatePomodoroConfig(taskId: string, config: Record<string, unknown> | null) {
const index = tasks.value.findIndex(t => t.id === taskId)
const previous: Task | null = index !== -1 ? { ...tasks.value[index] } as Task : null
const patch: Partial<Task> = config === null
? { isPomodoroTask: false }
: { ...config, isPomodoroTask: true }

if (index !== -1) {
tasks.value[index] = { ...tasks.value[index], ...config } as Task
tasks.value[index] = { ...tasks.value[index], ...patch } as Task
}
if (selectedTask.value?.id === taskId) {
selectedTask.value = { ...selectedTask.value, ...config } as Task
selectedTask.value = { ...selectedTask.value, ...patch } as Task
}

try {
await store.updatePomodoroConfig(taskId, config)
} catch {
}
catch {
if (previous && index !== -1) {
tasks.value[index] = previous
selectedTask.value = previous
Expand Down
35 changes: 32 additions & 3 deletions alfy-bot-frontend/src/features/tasks/ui/TaskDetailDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,25 +108,36 @@
</div>

<!-- Pomodoro section -->
<div v-if="task.isPomodoroTask" class="space-y-3">
<div v-if="task.isPomodoroTask || effectiveEditable" class="space-y-3">
<div class="flex items-center gap-2">
<div class="flex items-center justify-center w-7 h-7 rounded-md bg-red-50 dark:bg-red-500/10">
<Timer :size="14" class="text-red-500" />
</div>
<span class="text-sm font-medium">Помодоро</span>
<span class="ml-auto text-xs tabular-nums text-muted-foreground">
<span class="flex-1" />
<span
v-if="task.isPomodoroTask"
class="text-xs tabular-nums text-muted-foreground"
>
{{ formatPomodoro(task.pomodoroCompleted || 0) }}/{{ localPomodoroCount }}
</span>
<Button
v-if="task.isPomodoroTask"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="handleStartTimer"
>
<Play :size="14" class="text-red-500" />
</Button>
<Switch
v-if="effectiveEditable"
:checked="!!task.isPomodoroTask"
@update:checked="onPomodoroToggle"
/>
</div>
<PomodoroSettings
v-if="task.isPomodoroTask"
:count="localPomodoroCount"
:duration="localPomodoroDuration"
:short-break="localShortBreak"
Expand Down Expand Up @@ -533,6 +544,7 @@ import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { ContentEditableInput } from '@/components/ui/content-editable-input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
Expand All @@ -552,6 +564,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 { countFromDurationMinutes } from '../lib/duration'
import { toTimerTask, useTimerStore } from '@/features/task-timer'

const URGENCY_CLASSES: Record<DueDateUrgency, string> = {
Expand Down Expand Up @@ -625,7 +638,7 @@ const emit = defineEmits<{
shortBreak: number
longBreak: number
longBreakInterval: number
}): void
} | null): void
}>()

// Contenteditable refs
Expand Down Expand Up @@ -821,6 +834,22 @@ function emitPomodoroUpdate() {
})
}

function onPomodoroToggle(enabled: boolean) {
if (!props.task) return
if (!enabled) {
emit('update:pomodoroConfig', props.task.id, null)
return
}
localPomodoroCount.value = props.task.durationMinutes
? countFromDurationMinutes(props.task.durationMinutes)
: POMODORO_DEFAULTS.count
localPomodoroDuration.value = POMODORO_DEFAULTS.duration
localShortBreak.value = POMODORO_DEFAULTS.shortBreak
localLongBreak.value = POMODORO_DEFAULTS.longBreak
localLongBreakInterval.value = POMODORO_DEFAULTS.longBreakInterval
emitPomodoroUpdate()
}

// Title
function commitTitle() {
if (props.task && localTitle.value !== props.task.title) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,29 @@ describe('useTaskDetailHandlers', () => {
expect(isDetailOpen.value).toBe(false)
expect(store.deleteTask).not.toHaveBeenCalled()
})

it('handleUpdatePomodoroConfig ставит isPomodoroTask на selectedTask', async () => {
const task = makeTask({ id: '1' })
store = createMockStore([task])
const { handleOpenTask, handleUpdatePomodoroConfig, selectedTask } = useTaskDetailHandlers(store as any, confirmFn)
handleOpenTask(task)

await handleUpdatePomodoroConfig('1', { pomodoroCount: 2, pomodoroDuration: 25 })

expect(selectedTask.value!.isPomodoroTask).toBe(true)
expect(selectedTask.value!.pomodoroCount).toBe(2)
expect(store.updatePomodoroConfig).toHaveBeenCalledWith('1', { pomodoroCount: 2, pomodoroDuration: 25 })
})

it('handleUpdatePomodoroConfig с null снимает isPomodoroTask', async () => {
const task = makeTask({ id: '1', isPomodoroTask: true, pomodoroCount: 4 })
store = createMockStore([task])
const { handleOpenTask, handleUpdatePomodoroConfig, selectedTask } = useTaskDetailHandlers(store as any, confirmFn)
handleOpenTask(task)

await handleUpdatePomodoroConfig('1', null)

expect(selectedTask.value!.isPomodoroTask).toBe(false)
expect(store.updatePomodoroConfig).toHaveBeenCalledWith('1', null)
})
})
16 changes: 16 additions & 0 deletions alfy-bot/src/modules/task/task.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,22 @@ describe('TaskService', () => {
expect(result.pomodoroConfig).toBeNull();
});

it('создаёт конфиг если его не было', async () => {
repo.findById.mockResolvedValue(makeTask({ pomodoroConfig: null }));

const result = await service.updatePomodoroConfig(1, 'task-1', {
pomodoroCount: 2,
});

expect(repo.updatePomodoroConfig).toHaveBeenCalledWith(
expect.objectContaining({ id: 'task-1', pomodoroConfig: null }),
{ pomodoroCount: 2 },
);
expect(result.pomodoroConfig).toEqual(
expect.objectContaining({ pomodoroCount: 2 }),
);
});

it('бросает NotFoundException для несуществующей задачи', async () => {
repo.findById.mockResolvedValue(null);

Expand Down
Loading