From 7eb7c720f8f267e07f6fddf1bac1c8491735ae3d Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Thu, 13 Aug 2026 13:32:12 +0500 Subject: [PATCH 1/2] feat(tasks): this/subsequent reschedule for recurring calendar events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the series grid when moving one occurrence, retarget the rule on «Сместить все», materialize ghosts, and pack overlapping blocks into columns. Co-authored-by: Cursor --- .../ui/confirm-dialog/ConfirmDialog.vue | 13 +- .../src/composables/useConfirm.ts | 16 +- .../calendar/lib/calendar-events.spec.ts | 156 +++++++ .../features/calendar/lib/calendar-events.ts | 124 ++++-- .../calendar/lib/layout-overlapping.ts | 90 +++++ .../features/calendar/ui/AllDaySection.vue | 19 +- .../calendar/ui/CalendarEventBlock.vue | 26 +- .../src/features/calendar/ui/DayColumn.vue | 7 +- .../src/features/calendar/ui/HourGrid.vue | 2 + .../features/calendar/ui/WeeklyCalendar.vue | 26 +- .../tasks/lib/use-recurring-reschedule.ts | 65 +++ .../tasks/lib/use-task-detail-handlers.ts | 27 +- .../src/features/tasks/model/recurrence.ts | 7 + .../src/features/tasks/model/task-store.ts | 53 ++- .../src/features/tasks/model/types.ts | 8 + .../tests/composables/useConfirm.spec.ts | 77 ++++ .../calendar/lib/layout-overlapping.spec.ts | 93 +++++ .../calendar/ui/AllDaySection.spec.ts | 13 + .../calendar/ui/CalendarEventBlock.spec.ts | 39 +- .../lib/use-recurring-reschedule.spec.ts | 155 +++++++ .../lib/use-task-detail-handlers.spec.ts | 22 + .../model/task-store-materialize.spec.ts | 46 +++ alfy-bot/src/app.module.ts | 2 + .../task/domain/recurrence.utils.spec.ts | 239 +++++++++++ .../modules/task/domain/recurrence.utils.ts | 120 +++++- .../task/dto/materialize-occurrence.dto.ts | 8 + .../src/modules/task/dto/update-task.dto.ts | 7 +- .../task/overdue-recurring.service.spec.ts | 92 +++++ .../modules/task/overdue-recurring.service.ts | 34 +- alfy-bot/src/modules/task/task.controller.ts | 11 + .../src/modules/task/task.service.spec.ts | 380 +++++++++++++++++- alfy-bot/src/modules/task/task.service.ts | 300 +++++++++++--- .../recurring-series-repair.service.ts | 45 +++ alfy-bot/src/shared/entities/task.entity.ts | 3 + 34 files changed, 2202 insertions(+), 123 deletions(-) create mode 100644 alfy-bot-frontend/src/features/calendar/lib/layout-overlapping.ts create mode 100644 alfy-bot-frontend/src/features/tasks/lib/use-recurring-reschedule.ts create mode 100644 alfy-bot-frontend/tests/composables/useConfirm.spec.ts create mode 100644 alfy-bot-frontend/tests/features/calendar/lib/layout-overlapping.spec.ts create mode 100644 alfy-bot-frontend/tests/features/tasks/lib/use-recurring-reschedule.spec.ts create mode 100644 alfy-bot-frontend/tests/features/tasks/model/task-store-materialize.spec.ts create mode 100644 alfy-bot/src/modules/task/dto/materialize-occurrence.dto.ts create mode 100644 alfy-bot/src/shared/database/recurring-series-repair.service.ts 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 @@