From b17e7ec1fd696aec0b88d46431c9efcb38e078e4 Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 01:42:32 +0200 Subject: [PATCH 01/11] month view --- src/components/AllDayBar.vue | 58 ++------- src/components/TopBar.vue | 2 - src/components/monthview/MonthEvent.vue | 99 +++++++++++++++ src/components/monthview/MonthView.vue | 149 ++++++++++++++++++++++ src/components/monthview/MonthWeek.vue | 159 ++++++++++++++++++++++++ src/composables/useCalendarDrag.ts | 79 ++++++++++-- src/utils/allDayEventLayout.ts | 69 ++++++++++ src/views/CalendarView.vue | 3 +- 8 files changed, 555 insertions(+), 63 deletions(-) create mode 100644 src/components/monthview/MonthEvent.vue create mode 100644 src/components/monthview/MonthView.vue create mode 100644 src/components/monthview/MonthWeek.vue create mode 100644 src/utils/allDayEventLayout.ts diff --git a/src/components/AllDayBar.vue b/src/components/AllDayBar.vue index 21168e4..1cb39ea 100644 --- a/src/components/AllDayBar.vue +++ b/src/components/AllDayBar.vue @@ -1,7 +1,8 @@ + + + + diff --git a/src/components/monthview/MonthView.vue b/src/components/monthview/MonthView.vue new file mode 100644 index 0000000..a2bb284 --- /dev/null +++ b/src/components/monthview/MonthView.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/monthview/MonthWeek.vue b/src/components/monthview/MonthWeek.vue new file mode 100644 index 0000000..13c2a26 --- /dev/null +++ b/src/components/monthview/MonthWeek.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/composables/useCalendarDrag.ts b/src/composables/useCalendarDrag.ts index faad10e..92b99cf 100644 --- a/src/composables/useCalendarDrag.ts +++ b/src/composables/useCalendarDrag.ts @@ -42,6 +42,9 @@ export function useCalendarDrag(refreshEvents: () => Promise) { let resizeDate: DateTime | null = null; let createAnchor: DateTime | null = null; let allDayAnchorIndex = 0; + let allDayGridColumns = 1; + let moveByDay = false; + let moveGrabOffsetDays = 0; let suppressClickUntil = 0; let longPressTimer: ReturnType | null = null; @@ -100,6 +103,19 @@ export function useCalendarDrag(refreshEvents: () => Promise) { return date.startOf('day').plus({ minutes: startHour * 60 + minutes }); } + function allDayIndex(clientX: number, clientY: number) { + const rect = allDayGrid?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0 || dates.length === 0) return 0; + + const columns = Math.max(1, Math.min(allDayGridColumns, dates.length)); + const rows = Math.ceil(dates.length / columns); + const column = Math.floor((clientX - rect.left) / (rect.width / columns)); + const row = Math.floor((clientY - rect.top) / (rect.height / rows)); + const index = Math.max(0, row) * columns + Math.max(0, Math.min(column, columns - 1)); + + return Math.max(0, Math.min(index, dates.length - 1)); + } + function gridMinutesFor(date: DateTime, columnDate = date) { const gridStart = columnDate.startOf('day').plus({ minutes: startHour * 60 }); return date.diff(gridStart, 'minutes').minutes; @@ -222,20 +238,42 @@ export function useCalendarDrag(refreshEvents: () => Promise) { }); } - function startCreateAllDay(event: PointerEvent, element: HTMLElement) { + function startCreateAllDay(event: PointerEvent, element: HTMLElement, columns = dates.length) { if (!isSupportedPointer(event) || hasPointerInteraction() || isActive() || dates.length === 0) return; - const rect = element.getBoundingClientRect(); - const anchorIndex = snapDay(event.clientX, rect); + allDayGrid = element; + allDayGridColumns = columns; + const anchorIndex = allDayIndex(event.clientX, event.clientY); const date = dates[anchorIndex] ?? DateTime.now(); startPointer(event, () => { - allDayGrid = element; allDayAnchorIndex = anchorIndex; mode.value = 'create-all-day'; activeColumnDate.value = null; source.value = null; - active.value = blankEvent(date.startOf('day'), date.endOf('day')); + active.value = blankEvent(date.startOf('day'), date.plus({ days: 1 }).startOf('day')); + }); + } + + function startMoveAllDay( + event: PointerEvent, + calendarEvent: CalendarEvent, + element: HTMLElement, + columns = dates.length, + ) { + if (!isSupportedPointer(event) || hasPointerInteraction() || isActive() || !canEdit(calendarEvent)) return; + + allDayGrid = element; + allDayGridColumns = columns; + const grabbedDate = dates[allDayIndex(event.clientX, event.clientY)] ?? calendarEvent.from; + + startPointer(event, () => { + mode.value = 'move'; + moveByDay = true; + moveGrabOffsetDays = Math.round(grabbedDate.startOf('day').diff(calendarEvent.from.startOf('day'), 'days').days); + source.value = calendarEvent; + active.value = { ...calendarEvent }; + activeColumnDate.value = grabbedDate; }); } @@ -304,11 +342,25 @@ export function useCalendarDrag(refreshEvents: () => Promise) { active.value = blankEvent(from, to); } - function updateCreateAllDay(clientX: number) { - const rect = allDayGrid?.getBoundingClientRect(); - if (!rect) return; + function updateMoveAllDay(clientX: number, clientY: number) { + const original = source.value; + const pointerDate = dates[allDayIndex(clientX, clientY)]; + if (!original || !pointerDate) return; + + const nextStartDay = pointerDate.minus({ days: moveGrabOffsetDays }).startOf('day'); + const dayDelta = Math.round(nextStartDay.diff(original.from.startOf('day'), 'days').days); + activeColumnDate.value = pointerDate; + active.value = { + ...original, + from: original.from.plus({ days: dayDelta }), + to: original.to.plus({ days: dayDelta }), + }; + } + + function updateCreateAllDay(clientX: number, clientY: number) { + if (!allDayGrid) return; - const pointerIndex = snapDay(clientX, rect); + const pointerIndex = allDayIndex(clientX, clientY); const firstIndex = Math.min(allDayAnchorIndex, pointerIndex); const lastIndex = Math.max(allDayAnchorIndex, pointerIndex); const firstDate = dates[firstIndex]; @@ -344,7 +396,8 @@ export function useCalendarDrag(refreshEvents: () => Promise) { switch (mode.value) { case 'move': - updateMove(event.clientX, event.clientY); + if (moveByDay) updateMoveAllDay(event.clientX, event.clientY); + else updateMove(event.clientX, event.clientY); break; case 'resize-top': case 'resize-bottom': @@ -354,7 +407,7 @@ export function useCalendarDrag(refreshEvents: () => Promise) { updateCreate(event.clientY); break; case 'create-all-day': - updateCreateAllDay(event.clientX); + updateCreateAllDay(event.clientX, event.clientY); break; } } @@ -386,6 +439,9 @@ export function useCalendarDrag(refreshEvents: () => Promise) { moved.value = false; saving.value = false; allDayGrid = null; + allDayGridColumns = 1; + moveByDay = false; + moveGrabOffsetDays = 0; createAnchor = null; resizeDate = null; } @@ -494,6 +550,7 @@ export function useCalendarDrag(refreshEvents: () => Promise) { startMove, startResize, startCreateAllDay, + startMoveAllDay, canEdit, isSourceEvent, consumeClick, diff --git a/src/utils/allDayEventLayout.ts b/src/utils/allDayEventLayout.ts new file mode 100644 index 0000000..cbc9688 --- /dev/null +++ b/src/utils/allDayEventLayout.ts @@ -0,0 +1,69 @@ +import type { DateTime } from 'luxon'; +import type { CalendarEvent } from '@/types/core'; + +export type VisibleDayRange = { + startIndex: number; + endIndex: number; + span: number; +}; + +export type LaidOutDayEvent = VisibleDayRange & { + event: CalendarEvent; + row: number; +}; + +export function getVisibleDayRange( + event: CalendarEvent, + viewStart: DateTime, + numberOfDays: number, +): VisibleDayRange | null { + if (numberOfDays <= 0 || event.to <= event.from) return null; + + const endsAtMidnight = event.to.toMillis() === event.to.startOf('day').toMillis(); + const lastEventDay = (endsAtMidnight ? event.to.minus({ milliseconds: 1 }) : event.to).startOf('day'); + const startIndex = Math.floor(event.from.startOf('day').diff(viewStart, 'days').days); + const endIndex = Math.floor(lastEventDay.diff(viewStart, 'days').days); + + if (endIndex < 0 || startIndex >= numberOfDays) return null; + + const firstDay = Math.max(0, startIndex); + const lastDay = Math.min(numberOfDays - 1, endIndex); + + return { + startIndex: firstDay, + endIndex: lastDay, + span: lastDay - firstDay + 1, + }; +} + +export function layoutEventsByDay( + events: CalendarEvent[], + viewStart: DateTime, + numberOfDays: number, +): LaidOutDayEvent[] { + const visibleEvents = events.flatMap((event) => { + const range = getVisibleDayRange(event, viewStart, numberOfDays); + return range ? [{ event, ...range }] : []; + }); + + visibleEvents.sort( + (a, b) => + a.startIndex - b.startIndex || + b.span - a.span || + a.event.from.toMillis() - b.event.from.toMillis() || + a.event.to.toMillis() - b.event.to.toMillis(), + ); + + const rowEnds: number[] = []; + return visibleEvents.map((item) => { + let rowIndex = rowEnds.findIndex((endIndex) => endIndex < item.startIndex); + + if (rowIndex === -1) rowIndex = rowEnds.length; + rowEnds[rowIndex] = item.endIndex; + + return { + ...item, + row: rowIndex + 1, + }; + }); +} diff --git a/src/views/CalendarView.vue b/src/views/CalendarView.vue index 642f486..75fd7d8 100644 --- a/src/views/CalendarView.vue +++ b/src/views/CalendarView.vue @@ -1,5 +1,6 @@ From 30f9831991732abe4f3c11eb8ea2cdbdea5e9d87 Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 01:59:35 +0200 Subject: [PATCH 02/11] fix small drag bugs --- src/components/monthview/MonthView.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/monthview/MonthView.vue b/src/components/monthview/MonthView.vue index a2bb284..1236e3d 100644 --- a/src/components/monthview/MonthView.vue +++ b/src/components/monthview/MonthView.vue @@ -31,8 +31,9 @@ const weeks = computed(() => Array.from({ length: 6 }, (_, index) => days.value. const displayedEvents = computed(() => { const activeEvent = drag.active.value; const showMovedEvent = drag.mode.value === 'move' && drag.moved.value; + if (!activeEvent || !showMovedEvent) return events.value; - return activeEvent && showMovedEvent ? [...events.value, activeEvent] : events.value; + return [...events.value.filter((event) => !drag.isSourceEvent(event)), activeEvent]; }); let latestRequest = 0; From fe14376e7f4f3405e9b0c10feb6c7f07ebb47404 Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 02:07:20 +0200 Subject: [PATCH 03/11] fix routing --- src/components/TopBar.vue | 21 ++++++++++++++++++--- src/composables/useKeyboard.ts | 5 ++++- src/utils.ts | 6 +++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/TopBar.vue b/src/components/TopBar.vue index 9c80a44..e7a010d 100644 --- a/src/components/TopBar.vue +++ b/src/components/TopBar.vue @@ -1,5 +1,6 @@ diff --git a/src/composables/useKeyboard.ts b/src/composables/useKeyboard.ts index 64ab50c..2089d13 100644 --- a/src/composables/useKeyboard.ts +++ b/src/composables/useKeyboard.ts @@ -69,8 +69,11 @@ export function useKeyboard() { return; // TODO if (inputNeededElsewhere()) return; e.preventDefault(); + + const activeDate = getCurrentViewDatetime(router.currentRoute.value.params); router.replace({ - params: { ...router.currentRoute.value.params, view: 'm' }, + name: 'calendar', + params: { view: 'm', year: activeDate.year, month: activeDate.month, day: activeDate.day }, }); }); diff --git a/src/utils.ts b/src/utils.ts index 612a5d9..c79614a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,7 +40,11 @@ export function moveView(back: boolean, router: Router) { switch (params.view) { case 'm': newDate = currentDatetime.plus({ month: sign }); - break; + router.replace({ + name: 'calendar', + params: { view: 'm', year: newDate.year, month: newDate.month, day: newDate.day }, + }); + return; case 'w': newDate = currentDatetime.plus({ days: 7 * sign }); router.replace(getWeekAlignedRedirect(newDate)); From a0dd290ce6197ba7dc7a40c3d3f3f529bc3ab74c Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 02:07:41 +0200 Subject: [PATCH 04/11] enable keyboard shortcut --- src/composables/useKeyboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/composables/useKeyboard.ts b/src/composables/useKeyboard.ts index 2089d13..de33063 100644 --- a/src/composables/useKeyboard.ts +++ b/src/composables/useKeyboard.ts @@ -66,7 +66,6 @@ export function useKeyboard() { // M -> switch to month view onKeyStroke('m', (e) => { - return; // TODO if (inputNeededElsewhere()) return; e.preventDefault(); From ca6dec6f6db82adacb71c8fd90aed302acf7a125 Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 02:13:33 +0200 Subject: [PATCH 05/11] fix the month side map --- src/components/MonthSideMap.vue | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/MonthSideMap.vue b/src/components/MonthSideMap.vue index 31c108d..fd91be7 100644 --- a/src/components/MonthSideMap.vue +++ b/src/components/MonthSideMap.vue @@ -12,6 +12,7 @@ const { monthNameLong } = useTranslation(); const route = useRoute(); const currentDatetime = ref(DateTime.now()); +const isMonthView = computed(() => route.params.view === 'm'); const monthTracker = ref(currentDatetime.value.month); const yearTracker = ref(currentDatetime.value.year); @@ -60,6 +61,12 @@ watch( const viewStart = getCurrentViewDatetime(route.params); currentDatetime.value = viewStart; + if (isMonthView.value) { + monthTracker.value = viewStart.month; + yearTracker.value = viewStart.year; + return; + } + const viewEnd = viewStart.plus({ days: getViewLengthInDays(route.params), }); @@ -102,6 +109,11 @@ function changeMonthNum(up: boolean) { function jumpToInterval(clickedDay: DateTime) { if (route.params.view === 'w') { router.replace(getWeekAlignedRedirect(clickedDay)); + } else if (isMonthView.value) { + router.replace({ + name: 'calendar', + params: { view: 'm', year: clickedDay.year, month: clickedDay.month, day: clickedDay.day }, + }); } else { router.replace({ params: { year: clickedDay.year, month: clickedDay.month, day: clickedDay.day } }); } @@ -131,11 +143,15 @@ function getIntervalFromDay(day: DateTime) { const hoverInterval = computed(() => { if (!hoveredDay.value) return null; + if (isMonthView.value) { + return { start: hoveredDay.value, end: hoveredDay.value }; + } + return getIntervalFromDay(hoveredDay.value); }); const pressedInterval = computed(() => { - if (!pressedDay.value) return null; + if (isMonthView.value || !pressedDay.value) return null; return getIntervalFromDay(pressedDay.value); }); @@ -172,10 +188,11 @@ const pressedInterval = computed(() => { class="day" :class="{ today: d.hasSame(DateTime.now(), 'day'), + 'selected-day': isMonthView && d.hasSame(currentDatetime, 'day'), 'not-this-month': d.month !== monthTracker, - 'in-range': d >= viewInterval.start && d <= viewInterval.end, - 'range-start': d.hasSame(viewInterval.start, 'day'), - 'range-end': d.hasSame(viewInterval.end, 'day'), + 'in-range': !isMonthView && d >= viewInterval.start && d <= viewInterval.end, + 'range-start': !isMonthView && d.hasSame(viewInterval.start, 'day'), + 'range-end': !isMonthView && d.hasSame(viewInterval.end, 'day'), 'pressed-range': pressedInterval && d >= pressedInterval.start && d <= pressedInterval.end, }" @click="jumpToInterval(d)" @@ -288,6 +305,12 @@ const pressedInterval = computed(() => { } } + &.selected-day { + border-radius: var(--small-border-radius); + background-color: var(--git-bg-color); + color: var(--text-color-hard); + } + &.not-this-month { color: color-mix(in srgb, var(--text-color) 30%, transparent); From a90d87572723f9dffe385f5ffe4ab10b3e8d17c8 Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 12:02:03 +0200 Subject: [PATCH 06/11] fix month side map colors --- src/components/MonthSideMap.vue | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/MonthSideMap.vue b/src/components/MonthSideMap.vue index fd91be7..0d3d744 100644 --- a/src/components/MonthSideMap.vue +++ b/src/components/MonthSideMap.vue @@ -287,7 +287,7 @@ const pressedInterval = computed(() => { text-decoration-thickness: 0.15rem; text-underline-offset: 0.2rem;*/ - &:not(.in-range) { + &:not(.in-range):not(.selected-day) { color: var(--git-color); } @@ -305,12 +305,6 @@ const pressedInterval = computed(() => { } } - &.selected-day { - border-radius: var(--small-border-radius); - background-color: var(--git-bg-color); - color: var(--text-color-hard); - } - &.not-this-month { color: color-mix(in srgb, var(--text-color) 30%, transparent); @@ -329,6 +323,16 @@ const pressedInterval = computed(() => { } } + &.today.not-this-month:not(.selected-day) { + color: color-mix(in srgb, var(--git-color) 45%, transparent); + } + + &.selected-day { + border-radius: var(--small-border-radius); + background-color: var(--git-bg-color); + color: var(--text-color-hard); + } + &.range-start { border-top-left-radius: var(--small-border-radius); border-bottom-left-radius: var(--small-border-radius); From ab6b8b3df2cb2f6b81c4827e9aa9a0f95094872a Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 12:23:54 +0200 Subject: [PATCH 07/11] layout fixes --- src/components/monthview/MonthEvent.vue | 27 +++++++++++---- src/components/monthview/MonthWeek.vue | 45 ++++++++++++++----------- src/utils/allDayEventLayout.ts | 18 ++++++++-- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/components/monthview/MonthEvent.vue b/src/components/monthview/MonthEvent.vue index fd4cb57..c630b5b 100644 --- a/src/components/monthview/MonthEvent.vue +++ b/src/components/monthview/MonthEvent.vue @@ -14,19 +14,21 @@ const props = defineProps<{ const eventStyle = computed(() => ({ '--event-color': getEventColorCSSVariable(toColorId(getTag(props.event.calendar, props.event.tagId ?? '')?.color)), })); +const showTime = computed(() => !isWholeDay(props.event)); diff --git a/src/components/monthview/MonthWeek.vue b/src/components/monthview/MonthWeek.vue index 0cd85cc..801be28 100644 --- a/src/components/monthview/MonthWeek.vue +++ b/src/components/monthview/MonthWeek.vue @@ -146,6 +146,7 @@ function openWeek(day: DateTime) { :event="item.event" :compact="item.compact" :interactive="item.event !== drag.active.value && drag.canEdit(item.event)" + :temporary="item.event === drag.active.value" :style="item.style" @pointerdown.stop="startMove(item.event, $event)" @click="openEvent(item.event, $event)" diff --git a/src/components/timeline/BaseEvent.vue b/src/components/timeline/BaseEvent.vue index f554921..197e825 100644 --- a/src/components/timeline/BaseEvent.vue +++ b/src/components/timeline/BaseEvent.vue @@ -4,19 +4,28 @@ import { computed } from 'vue'; const props = withDefaults( defineProps<{ - topStyle: string; - heightStyle: string; + topStyle?: string; + heightStyle?: string; title: string; - subtitle: string; + subtitle?: string; color?: string; temporary?: boolean; interactive?: boolean; resizable?: boolean; + variant?: 'timeline' | 'month'; + compact?: boolean; + withoutTime?: boolean; + showSubtitle?: boolean; }>(), { + subtitle: '', temporary: false, interactive: false, resizable: false, + variant: 'timeline', + compact: false, + withoutTime: false, + showSubtitle: true, }, ); @@ -29,8 +38,8 @@ const dynamicStyles = computed(() => { if (props.temporary) color = 'var(--event-color-git)'; return { - top: `calc(${props.topStyle} + 1.5px)`, - height: `calc(${props.heightStyle} - 2px)`, + ...(props.topStyle !== undefined ? { top: `calc(${props.topStyle} + 1.5px)` } : {}), + ...(props.heightStyle !== undefined ? { height: `calc(${props.heightStyle} - 2px)` } : {}), '--event-color': color, }; }); @@ -38,8 +47,15 @@ const dynamicStyles = computed(() => { From 672c9d93ae31cd34288f88dcfab6f7e256c24967 Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 13:57:35 +0200 Subject: [PATCH 10/11] closer "more" underline --- src/components/monthview/MonthWeek.vue | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/monthview/MonthWeek.vue b/src/components/monthview/MonthWeek.vue index 801be28..4cd6c37 100644 --- a/src/components/monthview/MonthWeek.vue +++ b/src/components/monthview/MonthWeek.vue @@ -232,16 +232,26 @@ function openWeek(day: DateTime) { padding-bottom: 0.2rem; .more-events-content { + position: relative; max-width: 100%; display: flex; align-items: center; gap: 0.1rem; overflow: hidden; - border-bottom: 1px solid currentColor; white-space: nowrap; cursor: pointer; pointer-events: auto; + &::after { + content: ''; + position: absolute; + right: 0; + bottom: 1px; + left: 0; + height: 1px; + background: currentColor; + } + svg { width: 0.8rem; height: 0.8rem; From ec60cec8d93275570e5d0e815cf587b3b2de7e4e Mon Sep 17 00:00:00 2001 From: firu11 Date: Thu, 6 Aug 2026 13:58:09 +0200 Subject: [PATCH 11/11] adjust guide --- src/views/Guide.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/Guide.vue b/src/views/Guide.vue index 8a04e4b..3859ade 100644 --- a/src/views/Guide.vue +++ b/src/views/Guide.vue @@ -109,13 +109,13 @@

These shortcuts work while the calendar is open and focus is not inside a form field.

  • N Create a new event
  • -
  • S Show or hide the sidebar
  • T Go to today
  • 4 Switch to the four-day view
  • W Switch to the week view
  • -
  • M (In progress) Switch to the month view
  • +
  • M Switch to the month view
  • Move to the previous period
  • Move to the next period
  • +
  • S Show or hide the sidebar
  • Esc Close the open dialog