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 @@ -279,4 +279,33 @@ describe('tasksToCalendarEvents — occupied series slots / ghosts from root', (
const events = tasksToCalendarEvents([root, overdue], weekStart, weekEnd)
expect(events.filter(e => e.isVirtual)).toHaveLength(0)
})

it('однодневное окно (day view) показывает реальный timed event, не ghost', () => {
const due = local(2026, 4, 29, 10, 0)
const task = baseTask({
id: 'daily-1',
dueDate: due,
recurrence: dailyRule,
})
const start = local(2026, 4, 29, 0, 0)
const end = local(2026, 4, 29, 23, 59)

const events = tasksToCalendarEvents([task], start, end)
expect(events.filter(e => !e.isVirtual)).toHaveLength(1)
expect(events.filter(e => e.isVirtual)).toHaveLength(0)
})

it('день без dueDate у daily-серии — только ghost (центр вьюпорта ≠ сегодня)', () => {
const task = baseTask({
id: 'daily-1',
dueDate: local(2026, 4, 29, 10, 0),
recurrence: dailyRule,
})
const start = local(2026, 5, 1, 0, 0)
const end = local(2026, 5, 1, 23, 59)

const events = tasksToCalendarEvents([task], start, end)
expect(events.filter(e => !e.isVirtual)).toHaveLength(0)
expect(events.filter(e => e.isVirtual)).toHaveLength(1)
})
})
24 changes: 23 additions & 1 deletion alfy-bot-frontend/src/features/calendar/lib/use-infinite-days.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,20 @@ export function useInfiniteDays(gridRef: Ref<HTMLElement | null>) {
})
}

function captureScrollLeft(): number {
return scrollLeft.value
}

function restoreScrollLeft(left: number) {
scrollLeft.value = left
if (gridRef.value)
gridRef.value.scrollLeft = left
}

function scrollToDate(date: Date, smooth = false) {
if (!gridRef.value) return
const target = dayOffset(date)
scrollLeft.value = target
gridRef.value.scrollTo({ left: target, behavior: smooth ? 'smooth' : 'instant' })
}

Expand All @@ -83,5 +94,16 @@ export function useInfiniteDays(gridRef: Ref<HTMLElement | null>) {
cancelAnimationFrame(rafId)
})

return { visibleDays, dateRange, centerDate, dayOffset, dateFromX, scrollToDate, initScroll, onScroll }
return {
visibleDays,
dateRange,
centerDate,
dayOffset,
dateFromX,
scrollToDate,
initScroll,
onScroll,
captureScrollLeft,
restoreScrollLeft,
}
}
36 changes: 36 additions & 0 deletions alfy-bot-frontend/src/features/calendar/lib/week.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isDateInRange, pickDayViewDate } from './week'

const local = (y: number, m: number, d: number) => new Date(y, m - 1, d)

describe('pickDayViewDate', () => {
const range = {
start: local(2026, 8, 10),
end: local(2026, 8, 16),
}

it('берёт сегодня, если сегодня в видимом диапазоне недели', () => {
expect(pickDayViewDate(range, local(2026, 8, 14))).toEqual(local(2026, 8, 14))
})

it('не берёт середину вьюпорта — только сегодня или левый край', () => {
const picked = pickDayViewDate(range, local(2026, 8, 10))
expect(picked).toEqual(local(2026, 8, 10))
expect(picked).not.toEqual(local(2026, 8, 13))
})

it('если сегодня вне диапазона — левый край видимой недели', () => {
expect(pickDayViewDate(range, local(2026, 8, 20))).toEqual(local(2026, 8, 10))
})
})

describe('isDateInRange', () => {
it('включает границы по календарному дню, игнорируя время', () => {
const start = new Date(2026, 7, 10, 0, 0)
const end = new Date(2026, 7, 16, 18, 30)
expect(isDateInRange(new Date(2026, 7, 10, 23, 0), start, end)).toBe(true)
expect(isDateInRange(new Date(2026, 7, 16, 9, 0), start, end)).toBe(true)
expect(isDateInRange(new Date(2026, 7, 9, 23, 0), start, end)).toBe(false)
expect(isDateInRange(new Date(2026, 7, 17, 0, 0), start, end)).toBe(false)
})
})
17 changes: 16 additions & 1 deletion alfy-bot-frontend/src/features/calendar/lib/week.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addDays, addWeeks, eachDayOfInterval, format, isSameDay, startOfWeek } from 'date-fns'
import { addDays, addWeeks, eachDayOfInterval, format, isSameDay, startOfDay, startOfWeek } from 'date-fns'
import { dateFnsLocale, weekStartsOn } from '@/composables/useLocale'

export function getWeekStart(date: Date): Date {
Expand Down Expand Up @@ -43,6 +43,21 @@ export function formatDayTitle(date: Date): string {
return format(date, 'd MMMM yyyy', { locale: dateFnsLocale.value })
}

export function isDateInRange(date: Date, start: Date, end: Date): boolean {
const t = startOfDay(date).getTime()
return t >= startOfDay(start).getTime() && t <= startOfDay(end).getTime()
}

export function pickDayViewDate(
range: { start: Date, end: Date },
today: Date = new Date(),
): Date {
const t = startOfDay(today)
if (isDateInRange(t, range.start, range.end))
return t
return startOfDay(range.start)
}

export function formatDateRange(start: Date, end: Date): string {
const startMonth = format(start, 'LLLL', { locale: dateFnsLocale.value })
const endMonth = format(end, 'LLLL', { locale: dateFnsLocale.value })
Expand Down
55 changes: 37 additions & 18 deletions alfy-bot-frontend/src/features/calendar/ui/WeeklyCalendar.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { CalendarDropPayload, CalendarEvent, CalendarViewMode } from '../model/types'
import { useElementSize } from '@vueuse/core'
import { addDays, startOfDay } from 'date-fns'
import { addDays, endOfDay, startOfDay } from 'date-fns'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRecurringReschedule } from '@/features/tasks/lib/use-recurring-reschedule'
Expand All @@ -10,7 +10,7 @@ import { tasksToCalendarEvents } from '../lib/calendar-events'
import { useCalendarDnd } from '../lib/use-calendar-dnd'
import { useGrabScroll } from '../lib/use-grab-scroll'
import { DAY_WIDTH, GUTTER_WIDTH, TRACK_WIDTH, useInfiniteDays } from '../lib/use-infinite-days'
import { formatDateRange, formatDayHeader, formatDayTitle, getWeekStart, isToday, navigateWeek } from '../lib/week'
import { formatDateRange, formatDayHeader, formatDayTitle, getWeekStart, isDateInRange, isToday, navigateWeek, pickDayViewDate } from '../lib/week'
import AllDaySection from './AllDaySection.vue'
import CalendarHeader from './CalendarHeader.vue'
import HourGrid from './HourGrid.vue'
Expand All @@ -33,12 +33,16 @@ const isDayView = computed(() => viewMode.value === 'day')
const {
visibleDays: weekDays,
dateRange,
centerDate,
dayOffset: weekDayOffset,
dateFromX: weekDateFromX,
scrollToDate,
onScroll: onWeekScroll,
captureScrollLeft,
restoreScrollLeft,
} = useInfiniteDays(gridRef)
const savedWeekScrollLeft = ref<number | null>(null)
const savedWeekRange = ref<{ start: Date, end: Date } | null>(null)
const suppressWeekScroll = ref(false)
const { width: gridWidth } = useElementSize(gridRef)
const {
grabbing,
Expand Down Expand Up @@ -69,7 +73,7 @@ function dateFromX(x: number): Date {
}

function onScroll() {
if (isDayView.value)
if (isDayView.value || suppressWeekScroll.value)
return
onWeekScroll()
}
Expand All @@ -92,10 +96,11 @@ const calendarEvents = computed(() => {
const days = visibleDays.value
if (days.length === 0)
return []
const start = days[0]!
const end = new Date(days[days.length - 1]!)
end.setHours(23, 59, 59, 999)
return tasksToCalendarEvents(taskStore.tasks, start, end)
return tasksToCalendarEvents(
taskStore.tasks,
startOfDay(days[0]!),
endOfDay(days[days.length - 1]!),
)
})

function scrollToCurrentHour() {
Expand All @@ -107,20 +112,34 @@ function scrollToCurrentHour() {

function onViewModeChange(mode: CalendarViewMode) {
if (mode === 'day') {
selectedDate.value = startOfDay(centerDate.value)
savedWeekScrollLeft.value = captureScrollLeft()
savedWeekRange.value = { start: dateRange.value.start, end: dateRange.value.end }
selectedDate.value = pickDayViewDate(dateRange.value)
viewMode.value = mode
nextTick(() => {
if (isToday(selectedDate.value))
scrollToCurrentHour()
})
return
}

const savedLeft = savedWeekScrollLeft.value
const savedRange = savedWeekRange.value
const restoreWeek = savedLeft != null && savedRange != null
&& isDateInRange(selectedDate.value, savedRange.start, savedRange.end)

suppressWeekScroll.value = true
if (restoreWeek && savedLeft != null)
restoreScrollLeft(savedLeft)
viewMode.value = mode
nextTick(() => {
if (!gridRef.value)
return
if (mode === 'day') {
gridRef.value.scrollLeft = 0
if (isToday(selectedDate.value))
scrollToCurrentHour()
}
else {
if (restoreWeek && savedLeft != null)
restoreScrollLeft(savedLeft)
else
scrollToDate(selectedDate.value)
}
requestAnimationFrame(() => {
suppressWeekScroll.value = false
})
})
}

Expand Down
Loading