From e2393704ddd91538d7f3eca3eb67a657f8feeca2 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:15:42 +0200 Subject: [PATCH 01/13] ENH - rework calendar navigation and views --- frontend/src/views/apps/CalendarApp.vue | 815 +++++++++++++++--------- 1 file changed, 522 insertions(+), 293 deletions(-) diff --git a/frontend/src/views/apps/CalendarApp.vue b/frontend/src/views/apps/CalendarApp.vue index 131a539..df30e27 100644 --- a/frontend/src/views/apps/CalendarApp.vue +++ b/frontend/src/views/apps/CalendarApp.vue @@ -3,7 +3,6 @@ import { Bell, CalendarDays, Check, - ChevronDown, ChevronLeft, ChevronRight, Circle, @@ -26,20 +25,31 @@ import { usePhoneStore } from '@/stores/phone' import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar' type CalendarScreen = 'detail' | 'form' | 'main' -type MonthViewMode = 'compact' | 'details' | 'list' | 'stacked' +type CalendarViewMode = 'compact' | 'details' | 'list' | 'stacked' type TimeField = 'endTime' | 'startTime' +type YearMonth = { + cells: Array + date: Date + key: string + label: string +} +type YearOverview = { + months: YearMonth[] + year: number +} const phone = usePhoneStore() const account = useAccountStore() const calendar = useCalendarStore() const today = new Date() -const visibleMonth = ref(new Date(today.getFullYear(), today.getMonth(), 1)) +const visibleYear = ref(today.getFullYear()) const selectedDate = ref(dateKey(today)) const selectedEvent = ref(null) const editingEvent = ref(null) const screen = ref('main') -const viewMode = ref('details') +const viewMode = ref('details') const viewMenuOpen = ref(false) +const yearOverviewOpen = ref(false) const searchOpen = ref(false) const searchQuery = ref('') const reminderOpen = ref(false) @@ -50,6 +60,8 @@ const pickerHour = ref('09') const pickerMinute = ref('00') const hourColumn = ref(null) const minuteColumn = ref(null) +const calendarScroll = ref(null) +const yearOverviewScroll = ref(null) const draft = reactive({ date: selectedDate.value, endTime: '10:00', @@ -67,7 +79,7 @@ const minuteOptions = Array.from({ length: 12 }, (_, index) => ) const viewModes: Array<{ icon: typeof LayoutGrid - id: MonthViewMode + id: CalendarViewMode labelKey: string }> = [ { @@ -98,12 +110,6 @@ const timePickerTitle = computed(() => ? phone.t('Apps.calendar.starts') : phone.t('Apps.calendar.ends'), ) -const monthLabel = computed(() => - new Intl.DateTimeFormat(phone.lang, { - month: 'long', - year: 'numeric', - }).format(visibleMonth.value), -) const weekdayLabels = computed(() => { const monday = new Date(2026, 0, 5) return Array.from({ length: 7 }, (_, index) => @@ -112,25 +118,40 @@ const weekdayLabels = computed(() => { ), ) }) -const monthDays = computed(() => { - const start = calendarGridStart(visibleMonth.value) - return Array.from({ length: 42 }, (_, index) => addDays(start, index)) -}) +const yearMonths = computed(() => + Array.from({ length: 12 }, (_, monthIndex) => { + const date = new Date(visibleYear.value, monthIndex, 1) + return { + cells: monthCells(date), + date, + key: `${visibleYear.value}-${String(monthIndex + 1).padStart(2, '0')}`, + label: new Intl.DateTimeFormat(phone.lang, { month: 'long' }).format( + date, + ), + } + }), +) +const yearOverview = computed(() => + Array.from({ length: 11 }, (_, yearIndex) => { + const year = visibleYear.value - 5 + yearIndex + return { + months: Array.from({ length: 12 }, (_, monthIndex) => { + const date = new Date(year, monthIndex, 1) + return { + cells: monthCells(date), + date, + key: `${year}-${String(monthIndex + 1).padStart(2, '0')}`, + label: new Intl.DateTimeFormat(phone.lang, { month: 'long' }).format( + date, + ), + } + }), + year, + } + }), +) const normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase()) -const selectedEvents = computed(() => - calendar.events - .filter((event) => dateKey(event.startsAt) === selectedDate.value) - .filter(matchesSearch) - .sort((left, right) => left.startsAt - right.startsAt), -) -const selectedDateLabel = computed(() => - new Intl.DateTimeFormat(phone.lang, { - day: 'numeric', - month: 'long', - weekday: 'long', - }).format(new Date(`${selectedDate.value}T12:00:00`)), -) -const monthEventGroups = computed(() => { +const eventsByDate = computed(() => { const groups = new Map() for (const event of [...calendar.events] .filter(matchesSearch) @@ -140,7 +161,10 @@ const monthEventGroups = computed(() => { events.push(event) groups.set(key, events) } - return [...groups.entries()].map(([key, events]) => ({ + return groups +}) +const monthEventGroups = computed(() => { + return [...eventsByDate.value.entries()].map(([key, events]) => ({ events, key, label: new Intl.DateTimeFormat(phone.lang, { @@ -157,10 +181,21 @@ function addDays(date: Date, amount: number): Date { return next } -function calendarGridStart(month: Date): Date { +function monthCells(month: Date): Array { const first = new Date(month.getFullYear(), month.getMonth(), 1) - const mondayOffset = (first.getDay() + 6) % 7 - return addDays(first, -mondayOffset) + const leading = (first.getDay() + 6) % 7 + const daysInMonth = new Date( + month.getFullYear(), + month.getMonth() + 1, + 0, + ).getDate() + const cellCount = Math.ceil((leading + daysInMonth) / 7) * 7 + return Array.from({ length: cellCount }, (_, index) => { + const day = index - leading + 1 + return day > 0 && day <= daysInMonth + ? new Date(month.getFullYear(), month.getMonth(), day) + : null + }) } function dateKey(value: Date | number): string { @@ -189,11 +224,7 @@ function matchesSearch(event: CalendarEvent): boolean { } function eventsForDay(day: Date): CalendarEvent[] { - const key = dateKey(day) - return calendar.events - .filter((event) => dateKey(event.startsAt) === key) - .filter(matchesSearch) - .sort((left, right) => left.startsAt - right.startsAt) + return eventsByDate.value.get(dateKey(day)) ?? [] } function formatTime(value: number): string { @@ -221,46 +252,72 @@ function reminderLabel(minutes: number | null): string { return phone.t('Apps.calendar.reminders.oneDay') } -async function loadMonth(): Promise { +async function loadYear(): Promise { if (!isAuthenticated.value) { calendar.events = [] return } - const start = calendarGridStart(visibleMonth.value) - await calendar.load(start.getTime(), addDays(start, 42).getTime()) + const start = new Date(visibleYear.value, 0, 1) + const end = new Date(visibleYear.value + 1, 0, 1) + await calendar.load(start.getTime(), end.getTime()) } -async function moveMonth(amount: number): Promise { - visibleMonth.value = new Date( - visibleMonth.value.getFullYear(), - visibleMonth.value.getMonth() + amount, - 1, - ) - selectedDate.value = dateKey(visibleMonth.value) - viewMenuOpen.value = false - await loadMonth() +async function moveYear(amount: number): Promise { + visibleYear.value += amount + selectedDate.value = `${visibleYear.value}-01-01` + await loadYear() + await nextTick() + calendarScroll.value?.scrollTo({ behavior: 'smooth', top: 0 }) } async function goToday(): Promise { const current = new Date() - visibleMonth.value = new Date(current.getFullYear(), current.getMonth(), 1) + visibleYear.value = current.getFullYear() selectedDate.value = dateKey(current) - await loadMonth() + await loadYear() + await nextTick() + if (yearOverviewOpen.value) { + yearOverviewScroll.value + ?.querySelector(`[data-year="${visibleYear.value}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } else { + scrollToMonth(current) + } } function selectDay(day: Date): void { selectedDate.value = dateKey(day) - if (day.getMonth() !== visibleMonth.value.getMonth()) { - visibleMonth.value = new Date(day.getFullYear(), day.getMonth(), 1) - void loadMonth() - } } -function selectView(mode: MonthViewMode): void { +function selectView(mode: CalendarViewMode): void { viewMode.value = mode viewMenuOpen.value = false } +async function openYearOverview(): Promise { + viewMenuOpen.value = false + yearOverviewOpen.value = true + await nextTick() + yearOverviewScroll.value + ?.querySelector(`[data-year="${visibleYear.value}"]`) + ?.scrollIntoView({ block: 'start' }) +} + +async function selectOverviewDay(day: Date): Promise { + visibleYear.value = day.getFullYear() + selectedDate.value = dateKey(day) + yearOverviewOpen.value = false + await loadYear() + await nextTick() + scrollToMonth(day, 'auto') +} + +function scrollToMonth(date: Date, behavior: ScrollBehavior = 'smooth'): void { + calendarScroll.value + ?.querySelector(`[data-month="${dateKey(date).slice(0, 7)}"]`) + ?.scrollIntoView({ behavior, block: 'start' }) +} + function toggleSearch(): void { searchOpen.value = !searchOpen.value if (!searchOpen.value) searchQuery.value = '' @@ -357,12 +414,8 @@ async function saveEvent(): Promise { return } selectedDate.value = draft.date - visibleMonth.value = new Date( - new Date(startsAt).getFullYear(), - new Date(startsAt).getMonth(), - 1, - ) - await loadMonth() + visibleYear.value = new Date(startsAt).getFullYear() + await loadYear() screen.value = 'main' } @@ -377,25 +430,44 @@ async function deleteEvent(): Promise { screen.value = 'main' } -onMounted(loadMonth) +onMounted(async () => { + await loadYear() + await nextTick() + scrollToMonth(today, 'auto') +})