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/api/goals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface QuestionWithScheduleItem {

export interface UpdateGoalDto {
status?: 'active' | 'completed' | 'archived' | 'deleted'
outcome?: 'success' | 'failure' | null
goal_name?: string
parent_goal_id?: number | null
}
Expand Down
2 changes: 1 addition & 1 deletion alfy-bot-frontend/src/components/GoalCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function subGoalsLabel(n: number) {

<!-- status + global badge -->
<div class="mb-3 flex items-center gap-2">
<GoalStatusBadge :status="goal.status" />
<GoalStatusBadge :status="goal.status" :outcome="goal.outcome" />
<span
v-if="goal.is_global"
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
Expand Down
20 changes: 14 additions & 6 deletions alfy-bot-frontend/src/components/GoalStatusBadge.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { GoalStatus } from '../types'
import type { GoalOutcome, GoalStatus } from '../types'

const props = defineProps<{ status: GoalStatus }>()
const props = defineProps<{ status: GoalStatus, outcome?: GoalOutcome | null }>()

const config: Record<GoalStatus, { label: string, classes: string, dot: string }> = {
active: {
Expand All @@ -10,9 +10,9 @@ const config: Record<GoalStatus, { label: string, classes: string, dot: string }
dot: 'bg-green-500',
},
completed: {
label: 'Завершена',
classes: 'text-gray-500 dark:text-gray-400',
dot: 'bg-gray-400',
label: 'Достигнута',
classes: 'text-emerald-700 dark:text-emerald-400',
dot: 'bg-emerald-500',
},
archived: {
label: 'В архиве',
Expand All @@ -26,7 +26,15 @@ const config: Record<GoalStatus, { label: string, classes: string, dot: string }
},
}

const cfg = config[props.status]
const failed = {
label: 'Неудача',
classes: 'text-rose-700 dark:text-rose-400',
dot: 'bg-rose-500',
}

const cfg = props.status === 'completed' && props.outcome === 'failure'
? failed
: config[props.status]
</script>

<template>
Expand Down
2 changes: 2 additions & 0 deletions alfy-bot-frontend/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type GoalStatus = 'active' | 'completed' | 'archived' | 'deleted'
export type GoalOutcome = 'success' | 'failure'
export type GoalType = 'SIMPLE' | 'SMART' | 'GLOBAL'
export type QuestionType = 'number' | 'text' | 'rating' | 'emoji_rating' | 'yes_no' | 'time_spent' | 'photo'
export type FrequencyType = 'daily' | 'weekly_days' | 'interval'
Expand Down Expand Up @@ -50,6 +51,7 @@ export interface Goal {
goal_start: string | null
goal_end: string | null
status: GoalStatus
outcome?: GoalOutcome | null
createdAt: string
is_global: boolean
parent_goal_id: number | null
Expand Down
81 changes: 77 additions & 4 deletions alfy-bot-frontend/src/views/GoalView.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { QuestionWithScheduleItem } from '../api/goals'
import type { GoalReportStatus } from '../api/reports'
import type { Goal, Question } from '../types'
import { Archive, Check, ChevronRight, MoreVertical, MoveUpRight, Pencil, Plus, RotateCcw, Trash2, Unlink } from 'lucide-vue-next'
import type { Goal, GoalOutcome, Question } from '../types'
import { Archive, Check, CheckCircle2, ChevronRight, Flag, MoreVertical, MoveUpRight, Pencil, Plus, RotateCcw, Trash2, Unlink, XCircle } from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageContainer from '@/components/PageContainer.vue'
Expand Down Expand Up @@ -86,6 +86,7 @@ let pendingTypeChangeSnapshot: { value: QuestionWithScheduleItem, count: number
type GoalAction = 'archive' | 'delete' | 'restore'
const goalActionPending = ref<GoalAction | null>(null)
let pendingGoalAction: GoalAction | null = null
const completeOpen = ref(false)

// Родительская global-цель (если текущая цель привязана к ней).
const parentGoal = ref<Goal | null>(null)
Expand Down Expand Up @@ -447,6 +448,24 @@ function onCancelGoalAction() {
pendingGoalAction = null
goalActionPending.value = null
}

async function completeGoal(outcome: GoalOutcome) {
if (!goal.value)
return
saving.value = true
saveError.value = null
try {
await updateGoal(goal.value.id, { status: 'completed', outcome })
completeOpen.value = false
await reloadGoal()
}
catch (e: unknown) {
saveError.value = e instanceof Error ? e.message : 'Не удалось завершить цель'
}
finally {
saving.value = false
}
}
</script>

<template>
Expand All @@ -458,7 +477,7 @@ function onCancelGoalAction() {
<AppHeader :title="goal.goal_name" :show-back="true">
<template #right>
<div class="flex items-center gap-2">
<GoalStatusBadge :status="goal.status" />
<GoalStatusBadge :status="goal.status" :outcome="goal.outcome" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
Expand Down Expand Up @@ -486,6 +505,12 @@ function onCancelGoalAction() {
Открепить
</DropdownMenuItem>
</template>
<template v-if="goal.status === 'active'">
<DropdownMenuItem data-testid="goal-action-complete" @click="completeOpen = true">
<Flag class="w-4 h-4 mr-2" />
Завершить
</DropdownMenuItem>
</template>
<template v-if="goal.status === 'active' || goal.status === 'completed'">
<DropdownMenuItem data-testid="goal-action-archive" @click="openGoalAction('archive')">
<Archive class="w-4 h-4 mr-2" />
Expand Down Expand Up @@ -557,6 +582,17 @@ function onCancelGoalAction() {
<Check class="size-4 text-green-500" />
Отчёт на сегодня заполнен
</div>
<Button
v-if="goal.status === 'active'"
size="lg"
variant="outline"
class="w-full"
data-testid="goal-complete-cta"
@click="completeOpen = true"
>
<Flag class="w-4 h-4 mr-2" />
Завершить цель
</Button>

<!-- summary (скрываем блок дат у бессрочных целей без дат) -->
<section v-if="hasDates">
Expand All @@ -566,7 +602,7 @@ function onCancelGoalAction() {
<SummaryCard
label="Дней до конца"
:value="goal.status === 'completed' ? '—' : daysLeftVal > 0 ? daysLeftVal : 'Истёк'"
:sub="goal.status === 'completed' ? 'Цель завершена' : undefined"
:sub="goal.status === 'completed' ? (goal.outcome === 'failure' ? 'Неудача' : 'Достигнута') : undefined"
:accent="goal.status === 'active' && daysLeftVal > 0"
/>
</div>
Expand Down Expand Up @@ -760,6 +796,43 @@ function onCancelGoalAction() {
</AlertDialogContent>
</AlertDialog>

<AlertDialog
:open="completeOpen"
@update:open="(o: boolean) => { if (!o) completeOpen = false }"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Завершить цель?</AlertDialogTitle>
<AlertDialogDescription>
Отметьте, чем закончилась цель. Её можно будет найти во вкладке «Завершённые».
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter class="flex-col sm:flex-row gap-2">
<AlertDialogCancel :disabled="saving">
Отмена
</AlertDialogCancel>
<Button
variant="outline"
class="text-rose-700 dark:text-rose-400"
data-testid="complete-failure"
:disabled="saving"
@click="completeGoal('failure')"
>
<XCircle class="w-4 h-4 mr-1" />
Неудача
</Button>
<Button
data-testid="complete-success"
:disabled="saving"
@click="completeGoal('success')"
>
<CheckCircle2 class="w-4 h-4 mr-1" />
Успех
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

<AlertDialog
:open="goalActionPending !== null"
@update:open="(o: boolean) => { if (!o) goalActionPending = null }"
Expand Down
66 changes: 66 additions & 0 deletions alfy-bot-frontend/tests/views/GoalView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,69 @@ describe('goalView — добавление вопроса', () => {
wrapper.unmount()
})
})

describe('goalView — завершение цели', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
const store = useQuestionTypesStore()
store.types = SERVER_TYPES
store.loaded = true
routeParams.id = '5'
vi.mocked(fetchGoalById).mockResolvedValue(makeGoal())
vi.mocked(fetchGoalReportStatus).mockResolvedValue({
goalId: 5,
date: '2026-06-04',
lastUnfilledDate: null,
questions: [],
allDone: true,
})
})

it('Успех → PATCH status=completed, outcome=success', async () => {
const goalsApi = await import('@/api/goals')
vi.mocked(goalsApi.updateGoal).mockResolvedValue({
...makeGoal(),
status: 'completed',
outcome: 'success',
})
const wrapper = mountGoal()
await flushPromises()

await wrapper.find('[data-testid="goal-complete-cta"]').trigger('click')
await flushPromises()

const success = document.body.querySelector('[data-testid="complete-success"]') as HTMLButtonElement
expect(success).toBeTruthy()
success.click()
await flushPromises()

expect(goalsApi.updateGoal).toHaveBeenCalledWith(5, {
status: 'completed',
outcome: 'success',
})
wrapper.unmount()
})

it('Неудача → PATCH outcome=failure', async () => {
const goalsApi = await import('@/api/goals')
vi.mocked(goalsApi.updateGoal).mockResolvedValue({
...makeGoal(),
status: 'completed',
outcome: 'failure',
})
const wrapper = mountGoal()
await flushPromises()

await wrapper.find('[data-testid="goal-complete-cta"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-testid="complete-failure"]') as HTMLButtonElement).click()
await flushPromises()

expect(goalsApi.updateGoal).toHaveBeenCalledWith(5, {
status: 'completed',
outcome: 'failure',
})
wrapper.unmount()
})
})
5 changes: 5 additions & 0 deletions alfy-bot/src/modules/bot/scenes/list-goals.scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,11 @@ ${periodLine}📊 Статус: ${statusLabel}
status: GoalStatus,
) {
await this.goalService.updateGoalStatus(goalId, status);
if (status === GOAL_STATUSES.COMPLETED) {
await this.goalService.update(goalId, { outcome: 'success' });
} else if (status === GOAL_STATUSES.ACTIVE) {
await this.goalService.update(goalId, { outcome: null });
}

let message = '';
switch (status) {
Expand Down
10 changes: 9 additions & 1 deletion alfy-bot/src/modules/goal/dto/goal-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,17 @@ export class GoalDto {
@ApiPropertyOptional({ example: '2026-05-01', nullable: true })
goal_end: string | null;

@ApiProperty({ example: 'active', enum: ['active', 'completed', 'deleted'] })
@ApiProperty({ example: 'active', enum: ['active', 'completed', 'archived', 'deleted'] })
status: string;

@ApiPropertyOptional({
example: 'success',
enum: ['success', 'failure'],
nullable: true,
description: 'Результат завершения: успех или неудача. null пока цель активна',
})
outcome: 'success' | 'failure' | null;

@ApiProperty({
example: false,
description: 'Глобальная цель (без дат и вопросов, может иметь подцели)',
Expand Down
10 changes: 10 additions & 0 deletions alfy-bot/src/modules/goal/dto/update-goal.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export class UpdateGoalDto {
@IsString()
goal_name?: string;

@ApiPropertyOptional({
example: 'success',
enum: ['success', 'failure'],
nullable: true,
description: 'Флаг завершения. Шлётся вместе со status=completed',
})
@IsOptional()
@IsIn(['success', 'failure'])
outcome?: 'success' | 'failure' | null;

@ApiPropertyOptional({
example: 1,
description:
Expand Down
31 changes: 31 additions & 0 deletions alfy-bot/src/modules/goal/goal.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,37 @@ describe('GoalController', () => {
expect(result).toBe(updated);
});

it('status=completed + outcome=failure пишет оба поля', async () => {
const userId = 42;
const goalId = 7;
const owned = makeGoal({ id: goalId, user_id: userId, status: 'active' });
const updated = makeGoal({
id: goalId,
user_id: userId,
status: 'completed',
outcome: 'failure',
});
goalService.findById
.mockResolvedValueOnce(owned)
.mockResolvedValueOnce(updated);
goalService.updateGoalStatus.mockResolvedValue(undefined);
goalService.update.mockResolvedValue(updated);

const dto: UpdateGoalDto = { status: 'completed', outcome: 'failure' };
const req = { user: { sub: userId } } as AuthRequestLike;

const result = await controller.update(req as never, goalId, dto);

expect(goalService.updateGoalStatus).toHaveBeenCalledWith(
goalId,
'completed',
);
expect(goalService.update).toHaveBeenCalledWith(goalId, {
outcome: 'failure',
});
expect(result).toBe(updated);
});

it('валидный parent_goal_id → assertValidParent + update', async () => {
const userId = 42;
const goalId = 7;
Expand Down
6 changes: 6 additions & 0 deletions alfy-bot/src/modules/goal/goal.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ export class GoalController {

if (dto.status) {
await this.goalService.updateGoalStatus(id, dto.status);
if (dto.status !== 'completed') {
await this.goalService.update(id, { outcome: null });
}
}
if (dto.outcome !== undefined) {
await this.goalService.update(id, { outcome: dto.outcome });
}
if (dto.goal_name) {
await this.goalService.update(id, { goal_name: dto.goal_name });
Expand Down
4 changes: 4 additions & 0 deletions alfy-bot/src/shared/entities/goal.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export class Goal {
@Column({ default: false })
is_global: boolean;

/** Set when status becomes completed: success | failure. Null while active. */
@Column({ type: 'text', nullable: true })
outcome: 'success' | 'failure' | null;

@Column({ type: 'integer', nullable: true })
parent_goal_id: number | null;

Expand Down
Loading