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
23 changes: 13 additions & 10 deletions alfy-bot-frontend/src/features/calendar/lib/calendar-styles.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
export function getPriorityEventClasses(priority?: string): string {
switch (priority) {
case 'high': return 'bg-red-100 border-red-300 text-red-900 dark:bg-red-500/25 dark:border-red-500/40 dark:text-white dark:backdrop-blur-sm'
case 'medium': return 'bg-amber-100 border-amber-300 text-amber-900 dark:bg-amber-500/25 dark:border-amber-500/40 dark:text-white dark:backdrop-blur-sm'
case 'low': return 'bg-sky-100 border-sky-300 text-sky-900 dark:bg-sky-500/25 dark:border-sky-500/40 dark:text-white dark:backdrop-blur-sm'
default: return 'bg-primary/15 border-primary/40 text-foreground dark:bg-primary/25 dark:text-white dark:backdrop-blur-sm'
}
}

export const OVERDUE_EVENT_CLASSES = 'bg-red-500/85 border-red-600 text-white dark:bg-red-500/65 dark:border-red-500 dark:backdrop-blur-sm shadow-[0_0_0_1px_rgba(239,68,68,0.35)]'
export const DEFAULT_EVENT_CLASSES =
'bg-primary/15 text-foreground dark:bg-primary/25 dark:text-white dark:backdrop-blur-sm'

export const EVENT_SIDE_STRIPE_CLASSES = 'absolute left-0 inset-y-0 w-[3px]'

export const COMPLETED_EVENT_CLASSES =
'border bg-emerald-100 border-emerald-300 text-emerald-900 dark:bg-emerald-500/25 dark:border-emerald-500/40 dark:text-white dark:backdrop-blur-sm'

export const VIRTUAL_EVENT_CLASSES =
'border border-dashed border-border bg-muted/40 text-muted-foreground dark:border-border/40 dark:bg-muted/60 dark:text-white dark:backdrop-blur-sm'

export const OVERDUE_EVENT_CLASSES =
'border bg-red-500/85 border-red-600 text-white dark:bg-red-500/65 dark:border-red-500 dark:backdrop-blur-sm shadow-[0_0_0_1px_rgba(239,68,68,0.35)]'
32 changes: 24 additions & 8 deletions alfy-bot-frontend/src/features/calendar/ui/AllDaySection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
v-for="event in getEventsForDay(day)"
:key="event.taskId"
:class="[
'text-[10px] px-1.5 py-0.5 rounded max-w-full border flex items-center gap-0.5',
'relative overflow-hidden text-[10px] px-1.5 py-0.5 rounded max-w-full flex items-center gap-0.5',
event.task.isOverdue
? 'cursor-not-allowed'
: event.isVirtual
Expand All @@ -30,16 +30,30 @@
event.task.isOverdue
? OVERDUE_EVENT_CLASSES
: event.completed
? 'bg-muted border-border line-through'
? 'border bg-muted border-border line-through'
: event.isVirtual
? 'border-dashed border-border/60 bg-muted'
: chipClasses(event),
? 'border border-dashed border-border/60 bg-muted'
: DEFAULT_EVENT_CLASSES,
highlightedTaskId === event.taskId && 'ring-2 ring-primary',
]"
:draggable="!event.isVirtual && !event.task.isOverdue"
@dragstart="onDragStart($event, event)"
@click.stop="$emit('open', event)"
>
<span
v-if="showsPriorityMarks(event)"
data-testid="event-side-stripe"
:class="[EVENT_SIDE_STRIPE_CLASSES, event.priority && getPriorityStripeClass(event.priority)]"
aria-hidden="true"
/>
<Flag
v-if="showsPriorityMarks(event)"
:size="10"
data-testid="priority-flag"
class="shrink-0"
:class="event.priority && getPriorityColor(event.priority)"
:aria-label="event.priority && PRIORITY_LABELS[event.priority]"
/>
<span class="truncate">{{ event.title }}</span>
<button
v-if="event.isVirtual"
Expand All @@ -60,10 +74,12 @@

<script setup lang="ts">
import { ref } from 'vue'
import { CalendarPlus } from 'lucide-vue-next'
import { CalendarPlus, Flag } from 'lucide-vue-next'
import { isSameDay } from 'date-fns'
import type { CalendarEvent, CalendarDropPayload } from '../model/types'
import { getPriorityEventClasses, OVERDUE_EVENT_CLASSES } from '../lib/calendar-styles'
import { PRIORITY_LABELS } from '@/features/tasks/model/constants'
import { getPriorityColor, getPriorityStripeClass } from '@/features/tasks/lib/priority'
import { DEFAULT_EVENT_CLASSES, EVENT_SIDE_STRIPE_CLASSES, OVERDUE_EVENT_CLASSES } from '../lib/calendar-styles'
import { highlightedTaskId } from '@/features/tasks/lib/use-recurring-reschedule'

const props = defineProps<{
Expand All @@ -87,8 +103,8 @@ function getEventsForDay(day: Date): CalendarEvent[] {
return props.events.filter(e => e.isAllDay && isSameDay(e.date, day))
}

function chipClasses(event: CalendarEvent): string {
return getPriorityEventClasses(event.priority)
function showsPriorityMarks(event: CalendarEvent): boolean {
return !!event.priority && !event.task.isOverdue && !event.isVirtual && !event.completed
}

function onDragStart(e: DragEvent, event: CalendarEvent) {
Expand Down
38 changes: 31 additions & 7 deletions alfy-bot-frontend/src/features/calendar/ui/CalendarEventBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@
<div
data-calendar-event-block
:class="[
'absolute rounded-md px-2 py-1 text-xs overflow-hidden border',
'absolute rounded-md px-2 py-1 text-xs overflow-hidden',
hidden ? 'invisible' : isOverdue ? 'cursor-not-allowed' : isVirtual ? 'cursor-default' : 'cursor-grab',
isOverdue
? OVERDUE_EVENT_CLASSES
: completed
? 'bg-emerald-100 border-emerald-300 text-emerald-900 dark:bg-emerald-500/25 dark:border-emerald-500/40 dark:text-white dark:backdrop-blur-sm'
? COMPLETED_EVENT_CLASSES
: isVirtual
? 'border-dashed border-border bg-muted/40 text-muted-foreground dark:border-border/40 dark:bg-muted/60 dark:text-white dark:backdrop-blur-sm'
: priorityClasses,
? VIRTUAL_EVENT_CLASSES
: DEFAULT_EVENT_CLASSES,
highlighted && 'ring-2 ring-inset ring-primary z-20',
]"
:style="blockStyle"
style="touch-action: none"
@pointerdown="onPointerDown"
>
<span
v-if="showPriorityMarks"
data-testid="event-side-stripe"
:class="[EVENT_SIDE_STRIPE_CLASSES, event.priority && getPriorityStripeClass(event.priority)]"
aria-hidden="true"
/>
<div :class="['font-medium flex items-center gap-1 min-w-0', completed && 'line-through']">
<RoundCheckbox
v-if="!isVirtual && !isOverdue"
Expand All @@ -27,6 +33,14 @@
@update:model-value="emit('toggle', event.taskId)"
/>
<Repeat v-if="event.isRecurring" :size="10" class="shrink-0" />
<Flag
v-if="showPriorityMarks"
:size="10"
data-testid="priority-flag"
class="shrink-0"
:class="event.priority && getPriorityColor(event.priority)"
:aria-label="event.priority && PRIORITY_LABELS[event.priority]"
/>
<span class="truncate">{{ event.title }}</span>
<button
v-if="isVirtual"
Expand Down Expand Up @@ -58,10 +72,18 @@

<script setup lang="ts">
import { computed } from 'vue'
import { CalendarPlus, Repeat, Timer } from 'lucide-vue-next'
import { CalendarPlus, Flag, Repeat, Timer } from 'lucide-vue-next'
import RoundCheckbox from '@/components/ui/roundCheckbox/RoundCheckbox.vue'
import type { CalendarEvent } from '../model/types'
import { getPriorityEventClasses, OVERDUE_EVENT_CLASSES } from '../lib/calendar-styles'
import { PRIORITY_LABELS } from '@/features/tasks/model/constants'
import { getPriorityColor, getPriorityStripeClass } from '@/features/tasks/lib/priority'
import {
COMPLETED_EVENT_CLASSES,
DEFAULT_EVENT_CLASSES,
EVENT_SIDE_STRIPE_CLASSES,
OVERDUE_EVENT_CLASSES,
VIRTUAL_EVENT_CLASSES,
} from '../lib/calendar-styles'
import { eventColumnStyle, type EventColumn } from '../lib/layout-overlapping'
import { highlightedTaskId } from '@/features/tasks/lib/use-recurring-reschedule'

Expand All @@ -83,8 +105,10 @@ const emit = defineEmits<{

const completed = computed(() => props.event.completed)
const isVirtual = computed(() => !!props.event.isVirtual)
const priorityClasses = computed(() => getPriorityEventClasses(props.event.priority))
const isOverdue = computed(() => !!props.event.task.isOverdue)
const showPriorityMarks = computed(() =>
!!props.event.priority && !isOverdue.value && !isVirtual.value && !completed.value,
)
const highlighted = computed(() => highlightedTaskId.value === props.event.taskId)

const blockStyle = computed(() => {
Expand Down
12 changes: 9 additions & 3 deletions alfy-bot-frontend/src/features/calendar/ui/HourGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,15 @@
v-if="showOverlay && draggedEvent"
:style="[overlayStyle, { height: overlayHeight + 'px' }]"
:class="[
'rounded-md px-2 py-1 text-xs overflow-hidden border pointer-events-none opacity-90 z-50 cursor-grabbing',
'relative rounded-md px-2 py-1 text-xs overflow-hidden pointer-events-none opacity-90 z-50 cursor-grabbing',
ghostClasses,
]"
>
<span
v-if="!draggedEvent.completed && !draggedEvent.task.isOverdue && draggedEvent.priority"
:class="[EVENT_SIDE_STRIPE_CLASSES, draggedEvent.priority && getPriorityStripeClass(draggedEvent.priority)]"
aria-hidden="true"
/>
<div class="font-medium truncate">{{ draggedEvent.title }}</div>
<div class="text-[10px] opacity-70">{{ overlayTimeLabel }}</div>
</div>
Expand All @@ -101,7 +106,8 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import { isSameDay } from 'date-fns'
import type { CalendarEvent, CalendarDropPayload } from '../model/types'
import { isToday } from '../lib/week'
import { getPriorityEventClasses } from '../lib/calendar-styles'
import { DEFAULT_EVENT_CLASSES, EVENT_SIDE_STRIPE_CLASSES } from '../lib/calendar-styles'
import { getPriorityStripeClass } from '@/features/tasks/lib/priority'
import { useCreateSlotDrag } from '../lib/use-create-slot-drag'
import { usePointerDnd } from '../lib/use-pointer-dnd'
import DayColumn from './DayColumn.vue'
Expand Down Expand Up @@ -156,7 +162,7 @@ const ghostClasses = computed(() => {
if (!draggedEvent.value) return ''
return draggedEvent.value.completed
? 'bg-muted border-border'
: getPriorityEventClasses(draggedEvent.value.priority)
: DEFAULT_EVENT_CLASSES
})

const now = ref(new Date())
Expand Down
10 changes: 10 additions & 0 deletions alfy-bot-frontend/src/features/tasks/lib/priority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ export const getPriorityClasses = (priority?: Priority): string => {
return PRIORITY_COLORS[priority]
}

const PRIORITY_STRIPE_COLORS: Record<Priority, string> = {
high: 'bg-red-500',
medium: 'bg-yellow-500',
low: 'bg-green-500',
}

export const getPriorityColor = (priority: Priority): string => {
return PRIORITY_ICON_COLORS[priority]
}

export const getPriorityStripeClass = (priority: Priority): string => {
return PRIORITY_STRIPE_COLORS[priority]
}
3 changes: 2 additions & 1 deletion alfy-bot-frontend/src/features/tasks/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export interface TaskCardEmits {
(e: 'open', task: Task): void
}

export type TaskPatch = Omit<Partial<Task>, 'dueDate'> & {
export type TaskPatch = Omit<Partial<Task>, 'dueDate' | 'priority'> & {
rescheduleScope?: RescheduleScope
dueDate?: Date | null
priority?: Priority | null
}
4 changes: 2 additions & 2 deletions alfy-bot-frontend/src/features/tasks/ui/PriorityPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import type { Priority } from '../model/types'
import PriorityPickerContent from './PriorityPickerContent.vue'

defineProps<{
modelValue: Priority | undefined
modelValue: Priority | null | undefined
}>()

defineEmits<{
(e: 'update:modelValue', value: Priority | undefined): void
(e: 'update:modelValue', value: Priority | null): void
}>()
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<button
v-if="modelValue"
class="flex items-center gap-2.5 w-full px-3 py-2.5 rounded-md text-sm text-muted-foreground hover:bg-muted/60 transition-colors cursor-pointer"
@click="$emit('update:modelValue', undefined)"
@click="$emit('update:modelValue', null)"
>
<X :size="14" />
Убрать
Expand All @@ -27,10 +27,10 @@ import { PRIORITY_LABELS, PRIORITIES } from '../model/constants'
import { getPriorityColor } from '../lib/priority'

defineProps<{
modelValue: Priority | undefined
modelValue: Priority | null | undefined
}>()

defineEmits<{
(e: 'update:modelValue', value: Priority | undefined): void
(e: 'update:modelValue', value: Priority | null): void
}>()
</script>
14 changes: 7 additions & 7 deletions alfy-bot-frontend/src/features/tasks/ui/TaskDetailDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -334,14 +334,14 @@
v-if="effectiveEditable && localPriority"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-sm text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
aria-label="Очистить приоритет"
@click.stop="localPriority = undefined; emitUpdate({ priority: undefined })"
@click.stop="localPriority = undefined; emitUpdate({ priority: null })"
>
<X :size="14" />
</button>
</div>
<PriorityPicker
:model-value="localPriority"
@update:model-value="localPriority = $event; emitUpdate({ priority: $event })"
@update:model-value="localPriority = $event ?? undefined; emitUpdate({ priority: $event })"
/>
</DropdownMenu>

Expand Down Expand Up @@ -560,7 +560,7 @@ const URGENCY_CLASSES: Record<DueDateUrgency, string> = {
normal: '',
none: '',
}
import type { Task, ChecklistItem } from '../model/types'
import type { Task, ChecklistItem, TaskPatch } from '../model/types'
import type { RecurrenceRule } from '../model/recurrence'
import { formatRecurrence } from '../model/recurrence'
import TagsEditor from './TagsEditor.vue'
Expand Down Expand Up @@ -706,8 +706,8 @@ function onMissedChange(value: 'shift' | 'freeze') {
}

// Drawer-specific handlers (auto-close for single-select)
function onPriorityDrawerChange(value: Priority | undefined) {
localPriority.value = value
function onPriorityDrawerChange(value: Priority | null) {
localPriority.value = value ?? undefined
emitUpdate({ priority: value })
activeDrawer.value = null
}
Expand Down Expand Up @@ -901,8 +901,8 @@ function autosizeTextarea(el: HTMLTextAreaElement) {
el.style.height = `${el.scrollHeight}px`
}

function emitUpdate(partial: Partial<Task>) {
function emitUpdate(partial: TaskPatch) {
if (!props.task) return
emit('update', { ...props.task, ...partial })
emit('update', { ...props.task, ...partial } as Task)
}
</script>
2 changes: 1 addition & 1 deletion alfy-bot-frontend/src/features/tasks/ui/TaskForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
</DropdownMenuTrigger>
<PriorityPicker
:model-value="form.priority"
@update:model-value="form.priority = $event"
@update:model-value="form.priority = $event ?? undefined"
/>
</DropdownMenu>

Expand Down
18 changes: 18 additions & 0 deletions alfy-bot-frontend/tests/features/calendar/ui/AllDaySection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ describe('AllDaySection', () => {
expect(chip.classes()).not.toContain('opacity-50')
})

it('приоритет — флаг, чип без цветной заливки', () => {
const wrapper = mount(AllDaySection, {
props: {
...defaultProps,
events: [makeEvent({ priority: 'high' })],
},
})
const chip = wrapper.find('[draggable="true"]')
expect(chip.classes()).toContain('bg-primary/15')
expect(chip.classes()).not.toContain('bg-red-100')
expect(chip.classes()).not.toContain('border-primary/40')
const flag = wrapper.find('[data-testid="priority-flag"]')
expect(flag.exists()).toBe(true)
expect(flag.classes()).toContain('text-red-500')
expect(wrapper.find('[data-testid="event-side-stripe"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="event-side-stripe"]').classes()).toContain('bg-red-500')
})

it('клик по event chip эмитит open с CalendarEvent', async () => {
const event = makeEvent()
const wrapper = mount(AllDaySection, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,42 @@ describe('CalendarEventBlock', () => {
expect(wrapper.classes()).not.toContain('opacity-40')
})

it('обычная задача рендерится с priority-классами', () => {
it('приоритет — флаг, карточка без цветной заливки', () => {
const wrapper = mount(CalendarEventBlock, {
props: { event: makeEvent({ priority: 'high' }) },
})
// high-priority стили адаптивны к теме: светлый базовый класс + dark-вариант
expect(wrapper.classes()).toContain('bg-red-100')
expect(wrapper.classes()).toContain('dark:bg-red-500/25')
expect(wrapper.classes()).toContain('bg-primary/15')
expect(wrapper.classes()).not.toContain('bg-red-100')
expect(wrapper.classes()).not.toContain('border-primary/40')
const flag = wrapper.find('[data-testid="priority-flag"]')
expect(flag.exists()).toBe(true)
expect(flag.classes()).toContain('text-red-500')
expect(wrapper.find('[data-testid="event-side-stripe"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="event-side-stripe"]').classes()).toContain('bg-red-500')
})

it('без приоритета нет флага и нет полоски', () => {
const wrapper = mount(CalendarEventBlock, {
props: { event: makeEvent() },
})
expect(wrapper.find('[data-testid="priority-flag"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="event-side-stripe"]').exists()).toBe(false)
})

it('выполненная и просроченная с приоритетом — без флага и полоски', () => {
const done = mount(CalendarEventBlock, {
props: { event: makeEvent({ priority: 'high', completed: true }) },
})
expect(done.find('[data-testid="priority-flag"]').exists()).toBe(false)
expect(done.find('[data-testid="event-side-stripe"]').exists()).toBe(false)
expect(done.classes()).toContain('bg-emerald-100')

const overdue = mount(CalendarEventBlock, {
props: { event: makeEvent({ priority: 'high', task: makeTask({ isOverdue: true }) }) },
})
expect(overdue.find('[data-testid="priority-flag"]').exists()).toBe(false)
expect(overdue.find('[data-testid="event-side-stripe"]').exists()).toBe(false)
expect(overdue.classes()).toContain('bg-red-500/85')
})

// Негативные
Expand Down
Loading
Loading