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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
{{ options?.message }}
</AlertDialogDescription>
</AlertDialogHeader>
<label
v-if="options?.rememberKey"
class="flex items-center gap-2 px-1 text-sm"
>
<Checkbox
:model-value="rememberChecked"
@update:model-value="(v) => (rememberChecked = v === true)"
/>
{{ options.rememberLabel }}
</label>
<AlertDialogFooter>
<AlertDialogCancel @click="handleCancel">
{{ options?.cancelText || 'Отмена' }}
Expand All @@ -24,6 +34,7 @@

<script setup lang="ts">
import { useConfirm } from '@/composables/useConfirm'
import { Checkbox } from '@/components/ui/checkbox'
import {
AlertDialog,
AlertDialogAction,
Expand All @@ -35,5 +46,5 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'

const { isOpen, options, handleConfirm, handleCancel } = useConfirm()
const { isOpen, options, handleConfirm, handleCancel, rememberChecked } = useConfirm()
</script>
16 changes: 14 additions & 2 deletions alfy-bot-frontend/src/composables/useConfirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ interface ConfirmOptions {
confirmText?: string
cancelText?: string
variant?: 'default' | 'destructive'
rememberKey?: string
rememberLabel?: string
}

const isOpen = ref(false)
const options = shallowRef<ConfirmOptions | null>(null)
const rememberChecked = ref(false)
let resolvePromise: ((value: boolean) => void) | null = null

const resolve = (value: boolean) => {
Expand All @@ -22,15 +25,24 @@ const resolve = (value: boolean) => {

export function useConfirm() {
const confirm = (opts: ConfirmOptions): Promise<boolean> => {
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) => {
resolvePromise = res
})
}

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 }
}
156 changes: 156 additions & 0 deletions alfy-bot-frontend/src/features/calendar/lib/calendar-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
124 changes: 82 additions & 42 deletions alfy-bot-frontend/src/features/calendar/lib/calendar-events.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<number> {
const keys = new Set<number>()
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<number>,
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<string, Task[]>()

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
Expand Down
Loading
Loading