diff --git a/alfy-bot-frontend/src/components/ui/confirm-dialog/ConfirmDialog.vue b/alfy-bot-frontend/src/components/ui/confirm-dialog/ConfirmDialog.vue
index b3da1ba..ba7b42b 100644
--- a/alfy-bot-frontend/src/components/ui/confirm-dialog/ConfirmDialog.vue
+++ b/alfy-bot-frontend/src/components/ui/confirm-dialog/ConfirmDialog.vue
@@ -7,6 +7,16 @@
{{ options?.message }}
+
{{ options?.cancelText || 'Отмена' }}
@@ -24,6 +34,7 @@
diff --git a/alfy-bot-frontend/src/composables/useConfirm.ts b/alfy-bot-frontend/src/composables/useConfirm.ts
index c465658..a7bc31f 100644
--- a/alfy-bot-frontend/src/composables/useConfirm.ts
+++ b/alfy-bot-frontend/src/composables/useConfirm.ts
@@ -6,10 +6,13 @@ interface ConfirmOptions {
confirmText?: string
cancelText?: string
variant?: 'default' | 'destructive'
+ rememberKey?: string
+ rememberLabel?: string
}
const isOpen = ref(false)
const options = shallowRef(null)
+const rememberChecked = ref(false)
let resolvePromise: ((value: boolean) => void) | null = null
const resolve = (value: boolean) => {
@@ -22,6 +25,10 @@ const resolve = (value: boolean) => {
export function useConfirm() {
const confirm = (opts: ConfirmOptions): Promise => {
+ if (opts.rememberKey && localStorage.getItem(opts.rememberKey) === '1') {
+ return Promise.resolve(true)
+ }
+ rememberChecked.value = false
options.value = opts
isOpen.value = true
return new Promise((res) => {
@@ -29,8 +36,13 @@ export function useConfirm() {
})
}
- const handleConfirm = () => resolve(true)
+ const handleConfirm = () => {
+ if (options.value?.rememberKey && rememberChecked.value) {
+ localStorage.setItem(options.value.rememberKey, '1')
+ }
+ resolve(true)
+ }
const handleCancel = () => resolve(false)
- return { isOpen, options, confirm, handleConfirm, handleCancel }
+ return { isOpen, options, confirm, handleConfirm, handleCancel, rememberChecked }
}
diff --git a/alfy-bot-frontend/src/features/calendar/lib/calendar-events.spec.ts b/alfy-bot-frontend/src/features/calendar/lib/calendar-events.spec.ts
index ac2cc03..c3fd923 100644
--- a/alfy-bot-frontend/src/features/calendar/lib/calendar-events.spec.ts
+++ b/alfy-bot-frontend/src/features/calendar/lib/calendar-events.spec.ts
@@ -124,3 +124,159 @@ describe('tasksToCalendarEvents — past-due recurring skip-to-today', () => {
expect(taskEvents[0]!.isVirtual).toBe(false)
})
})
+
+describe('tasksToCalendarEvents — this-only reschedule keeps series ghosts', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(local(2026, 4, 8, 12, 0))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('real event at dueDate, ghosts from recurrenceAnchorDate (Mon 10:00 → Wed 15:00)', () => {
+ const weekly: RecurrenceRule = { frequency: 'weekly', interval: 1 }
+ const task = baseTask({
+ id: 'moved',
+ dueDate: local(2026, 4, 8, 15, 0),
+ recurrenceAnchorDate: local(2026, 4, 6, 10, 0),
+ recurrence: weekly,
+ })
+ const weekStart = local(2026, 4, 6, 0, 0)
+ const weekEnd = local(2026, 4, 19, 23, 59)
+
+ const events = tasksToCalendarEvents([task], weekStart, weekEnd)
+ const real = events.filter(e => !e.isVirtual)
+ const ghosts = events.filter(e => e.isVirtual)
+
+ expect(real).toHaveLength(1)
+ expect(real[0]!.date).toEqual(local(2026, 4, 8, 15, 0))
+
+ expect(ghosts).toHaveLength(1)
+ expect(ghosts[0]!.date).toEqual(local(2026, 4, 13, 10, 0))
+ })
+})
+
+describe('tasksToCalendarEvents — occupied series slots / ghosts from root', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(local(2026, 4, 6, 12, 0))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const weekly: RecurrenceRule = { frequency: 'weekly', interval: 1 }
+
+ it('не рисует ghost на дне проявленного sibling', () => {
+ const root = baseTask({
+ id: 'root-1',
+ dueDate: local(2026, 4, 6, 10, 0),
+ recurrence: weekly,
+ })
+ const materialized = baseTask({
+ id: 'mat-1',
+ recurringParentId: 'root-1',
+ dueDate: local(2026, 4, 13, 10, 0),
+ recurrence: weekly,
+ isAutoCreated: false,
+ })
+ const weekStart = local(2026, 4, 6, 0, 0)
+ const weekEnd = local(2026, 4, 19, 23, 59)
+
+ const events = tasksToCalendarEvents([root, materialized], weekStart, weekEnd)
+ const ghosts = events.filter(e => e.isVirtual)
+ const reals = events.filter(e => !e.isVirtual)
+
+ expect(reals.map(e => e.taskId).sort()).toEqual(['mat-1', 'root-1'])
+ expect(ghosts).toHaveLength(0)
+ })
+
+ it('ghosts от root, не от проявленной; занятый слот sibling не рисуется', () => {
+ const root = baseTask({
+ id: 'root-1',
+ dueDate: local(2026, 4, 6, 10, 0),
+ recurrence: weekly,
+ completed: true,
+ recurringCompletedCount: 1,
+ })
+ const current = baseTask({
+ id: 'inst-1',
+ recurringParentId: 'root-1',
+ dueDate: local(2026, 4, 13, 10, 0),
+ recurrence: weekly,
+ isAutoCreated: true,
+ })
+ const materialized = baseTask({
+ id: 'mat-1',
+ recurringParentId: 'root-1',
+ dueDate: local(2026, 4, 20, 10, 0),
+ recurrenceAnchorDate: local(2026, 4, 20, 10, 0),
+ recurrence: weekly,
+ isAutoCreated: false,
+ })
+ const weekStart = local(2026, 4, 6, 0, 0)
+ const weekEnd = local(2026, 4, 27, 23, 59)
+
+ const events = tasksToCalendarEvents(
+ [root, current, materialized],
+ weekStart,
+ weekEnd,
+ )
+ const ghosts = events.filter(e => e.isVirtual)
+ const reals = events.filter(e => !e.isVirtual)
+
+ expect(reals).toHaveLength(3)
+ expect(ghosts).toHaveLength(1)
+ expect(ghosts[0]!.date).toEqual(local(2026, 4, 27, 10, 0))
+ expect(ghosts[0]!.taskId.startsWith('inst-1__virtual__')).toBe(true)
+ })
+
+ it('this-only sibling занимает слот якоря, не dueDate', () => {
+ const root = baseTask({
+ id: 'root-1',
+ dueDate: local(2026, 4, 6, 10, 0),
+ recurrence: weekly,
+ })
+ const materialized = baseTask({
+ id: 'mat-1',
+ recurringParentId: 'root-1',
+ dueDate: local(2026, 4, 15, 15, 0),
+ recurrenceAnchorDate: local(2026, 4, 13, 10, 0),
+ recurrence: weekly,
+ isAutoCreated: false,
+ })
+ const weekStart = local(2026, 4, 6, 0, 0)
+ const weekEnd = local(2026, 4, 19, 23, 59)
+
+ const events = tasksToCalendarEvents([root, materialized], weekStart, weekEnd)
+ const ghosts = events.filter(e => e.isVirtual)
+ const reals = events.filter(e => !e.isVirtual)
+
+ expect(reals.map(e => e.date.getDate()).sort((a, b) => a - b)).toEqual([6, 15])
+ expect(ghosts).toHaveLength(0)
+ })
+
+ it('не рисует ghosts если в семье нет живого курсора (только overdue/completed)', () => {
+ const root = baseTask({
+ id: 'root-1',
+ dueDate: local(2026, 4, 6, 18, 0),
+ recurrence: weekly,
+ completed: true,
+ })
+ const overdue = baseTask({
+ id: 'ov-1',
+ recurringParentId: 'root-1',
+ dueDate: local(2026, 4, 8, 18, 0),
+ isOverdue: true,
+ recurrence: weekly,
+ })
+ const weekStart = local(2026, 4, 6, 0, 0)
+ const weekEnd = local(2026, 4, 19, 23, 59)
+
+ const events = tasksToCalendarEvents([root, overdue], weekStart, weekEnd)
+ expect(events.filter(e => e.isVirtual)).toHaveLength(0)
+ })
+})
diff --git a/alfy-bot-frontend/src/features/calendar/lib/calendar-events.ts b/alfy-bot-frontend/src/features/calendar/lib/calendar-events.ts
index 135b9ea..c3b50b2 100644
--- a/alfy-bot-frontend/src/features/calendar/lib/calendar-events.ts
+++ b/alfy-bot-frontend/src/features/calendar/lib/calendar-events.ts
@@ -1,10 +1,11 @@
-import { isSameDay } from 'date-fns'
+import { isSameDay, startOfDay } from 'date-fns'
import type { Task } from '@/features/tasks/model/types'
import type { CalendarEvent } from '../model/types'
import { computeTaskDurationMinutes } from '@/features/tasks/lib/duration'
import {
computeNextDueDate,
findNextOccurrenceOnOrAfter,
+ seriesDueDate,
} from '@/features/tasks/model/recurrence'
function taskToEvent(task: Task, date: Date, isVirtual = false): CalendarEvent {
@@ -31,56 +32,95 @@ function taskToEvent(task: Task, date: Date, isVirtual = false): CalendarEvent {
}
}
-export function tasksToCalendarEvents(tasks: Task[], weekStart: Date, weekEnd: Date): CalendarEvent[] {
- const events: CalendarEvent[] = []
+function familyId(task: Task): string {
+ return task.recurringParentId ?? task.id
+}
- for (const task of tasks) {
- if (!task.dueDate) continue
+function isLiveCursor(task: Task): boolean {
+ return !task.completed && !task.isOverdue
+}
- const dueDate = new Date(task.dueDate)
- const isRecurring = !!task.recurrence
+function occupiedSlotKeys(members: Task[]): Set {
+ const keys = new Set()
+ for (const member of members) {
+ const slot = seriesDueDate(member)
+ if (slot) keys.add(startOfDay(slot).getTime())
+ if (member.dueDate) keys.add(startOfDay(new Date(member.dueDate)).getTime())
+ }
+ return keys
+}
- // Add the real event if it falls in range
- if (dueDate >= weekStart && dueDate <= weekEnd) {
- events.push(taskToEvent(task, dueDate))
- }
+function emitGhosts(
+ root: Task,
+ occupied: Set,
+ weekStart: Date,
+ weekEnd: Date,
+ events: CalendarEvent[],
+) {
+ if (!root.recurrence) return
+ const seriesDue = seriesDueDate(root)
+ if (!seriesDue) return
- // Generate virtual future occurrences for uncompleted recurring tasks
- if (isRecurring && !task.completed && task.recurrence) {
- let current = dueDate
- const completedCount = task.recurringCompletedCount ?? 0
-
- // Skip-to-today: for past-due recurring tasks we must not project ghosts
- // onto past dates between dueDate and today. Mirrors the backend
- // findNextOccurrenceOnOrAfter logic in completeRecurringTask.
- const startOfToday = new Date()
- startOfToday.setHours(0, 0, 0, 0)
- if (dueDate < startOfToday) {
- const skipped = findNextOccurrenceOnOrAfter(
- dueDate,
- task.recurrence,
- startOfToday,
- completedCount,
- )
- if (!skipped) continue
- current = skipped
- if (skipped <= weekEnd && skipped >= weekStart && !isSameDay(skipped, dueDate)) {
- events.push(taskToEvent(task, skipped, true))
- }
- }
+ const completedCount = root.recurringCompletedCount ?? 0
+
+ const maybeEmit = (date: Date) => {
+ if (date < weekStart || date > weekEnd) return
+ if (occupied.has(startOfDay(date).getTime())) return
+ events.push(taskToEvent(root, date, true))
+ }
- for (let i = 0; i < 52; i++) {
- const next = computeNextDueDate(current, task.recurrence, completedCount)
- if (!next) break
- if (next > weekEnd) break
+ let current = seriesDue
+ const startOfToday = new Date()
+ startOfToday.setHours(0, 0, 0, 0)
+ if (seriesDue < startOfToday) {
+ const skipped = findNextOccurrenceOnOrAfter(
+ seriesDue,
+ root.recurrence,
+ startOfToday,
+ completedCount,
+ )
+ if (!skipped) return
+ current = skipped
+ maybeEmit(skipped)
+ }
+
+ for (let i = 0; i < 52; i++) {
+ const next = computeNextDueDate(current, root.recurrence, completedCount)
+ if (!next) break
+ if (next > weekEnd) break
+ if (next >= weekStart) maybeEmit(next)
+ current = next
+ }
+}
- if (next >= weekStart && !isSameDay(next, dueDate)) {
- events.push(taskToEvent(task, next, true))
- }
+export function tasksToCalendarEvents(tasks: Task[], weekStart: Date, weekEnd: Date): CalendarEvent[] {
+ const events: CalendarEvent[] = []
+ const families = new Map()
- current = next
+ for (const task of tasks) {
+ if (task.dueDate) {
+ const dueDate = new Date(task.dueDate)
+ if (dueDate >= weekStart && dueDate <= weekEnd) {
+ events.push(taskToEvent(task, dueDate))
}
}
+
+ if (task.recurrence || task.recurringParentId) {
+ const id = familyId(task)
+ const members = families.get(id)
+ if (members) members.push(task)
+ else families.set(id, [task])
+ }
+ }
+
+ for (const [, members] of families) {
+ const live = members.filter(isLiveCursor)
+ const source =
+ live.find(t => !t.recurringParentId)
+ ?? live.find(t => t.isAutoCreated)
+ ?? live[0]
+ if (!source?.recurrence) continue
+ emitGhosts(source, occupiedSlotKeys(members), weekStart, weekEnd, events)
}
return events
diff --git a/alfy-bot-frontend/src/features/calendar/lib/layout-overlapping.ts b/alfy-bot-frontend/src/features/calendar/lib/layout-overlapping.ts
new file mode 100644
index 0000000..554b0c6
--- /dev/null
+++ b/alfy-bot-frontend/src/features/calendar/lib/layout-overlapping.ts
@@ -0,0 +1,90 @@
+import type { CalendarEvent } from '../model/types'
+
+/** Visual min height is 20px; hour row is 60px → 20 minutes. */
+export const MIN_VISUAL_MINUTES = 20
+
+export interface EventColumn {
+ col: number
+ cols: number
+}
+
+function visualEnd(event: CalendarEvent): number {
+ return event.startMinutes + Math.max(event.durationMinutes, MIN_VISUAL_MINUTES)
+}
+
+function layoutGroup(
+ group: CalendarEvent[],
+ result: Map,
+): void {
+ const columns: CalendarEvent[][] = []
+ const colOf = new Map()
+
+ for (const event of group) {
+ let placed = false
+ for (let i = 0; i < columns.length; i++) {
+ const last = columns[i]![columns[i]!.length - 1]!
+ if (visualEnd(last) <= event.startMinutes) {
+ columns[i]!.push(event)
+ colOf.set(event.taskId, i)
+ placed = true
+ break
+ }
+ }
+ if (!placed) {
+ colOf.set(event.taskId, columns.length)
+ columns.push([event])
+ }
+ }
+
+ const cols = columns.length
+ for (const event of group) {
+ result.set(event.taskId, { col: colOf.get(event.taskId)!, cols })
+ }
+}
+
+/** Outlook-style columns: overlapping events share width, touching ones stay full-width. */
+export function assignEventColumns(events: CalendarEvent[]): Map {
+ const result = new Map()
+ if (events.length === 0) return result
+
+ const sorted = [...events].sort(
+ (a, b) =>
+ a.startMinutes - b.startMinutes
+ || b.durationMinutes - a.durationMinutes
+ || a.taskId.localeCompare(b.taskId),
+ )
+
+ let group: CalendarEvent[] = []
+ let groupEnd = -1
+
+ const flush = () => {
+ if (group.length === 0) return
+ layoutGroup(group, result)
+ group = []
+ groupEnd = -1
+ }
+
+ for (const event of sorted) {
+ if (group.length > 0 && event.startMinutes >= groupEnd) flush()
+ group.push(event)
+ groupEnd = Math.max(groupEnd, visualEnd(event))
+ }
+ flush()
+ return result
+}
+
+const INSET_PX = 2
+
+export function eventColumnStyle(layout: EventColumn): Record {
+ if (layout.cols <= 1) {
+ return { left: `${INSET_PX}px`, right: `${INSET_PX}px`, zIndex: 1 }
+ }
+ const leftPct = (layout.col / layout.cols) * 100
+ const widthPct = 100 / layout.cols
+ return {
+ left: `calc(${leftPct}% + ${INSET_PX}px)`,
+ width: `calc(${widthPct}% - ${INSET_PX * 2}px)`,
+ right: 'auto',
+ zIndex: layout.col + 1,
+ }
+}
diff --git a/alfy-bot-frontend/src/features/calendar/ui/AllDaySection.vue b/alfy-bot-frontend/src/features/calendar/ui/AllDaySection.vue
index e13a211..cc748a0 100644
--- a/alfy-bot-frontend/src/features/calendar/ui/AllDaySection.vue
+++ b/alfy-bot-frontend/src/features/calendar/ui/AllDaySection.vue
@@ -21,7 +21,7 @@
v-for="event in getEventsForDay(day)"
:key="event.taskId"
:class="[
- 'text-[10px] px-1.5 py-0.5 rounded truncate max-w-full border',
+ 'text-[10px] px-1.5 py-0.5 rounded max-w-full border flex items-center gap-0.5',
event.task.isOverdue
? 'cursor-not-allowed'
: event.isVirtual
@@ -34,12 +34,24 @@
: event.isVirtual
? 'border-dashed border-border/60 bg-muted'
: chipClasses(event),
+ highlightedTaskId === event.taskId && 'ring-2 ring-primary',
]"
:draggable="!event.isVirtual && !event.task.isOverdue"
@dragstart="onDragStart($event, event)"
@click.stop="$emit('open', event)"
>
- {{ event.title }}
+ {{ event.title }}
+
@@ -48,9 +60,11 @@