diff --git a/src/assets/locales/cs.json b/src/assets/locales/cs.json index 14ef011..896ebe5 100644 --- a/src/assets/locales/cs.json +++ b/src/assets/locales/cs.json @@ -23,6 +23,7 @@ "from": "Od", "to": "Do", "entireDay": "Celý den", + "more": "další…", "repeat": { "repeat": "Opakování", "end": "Konec", diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 9d6270a..482e2b9 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -23,6 +23,7 @@ "from": "From", "to": "To", "entireDay": "Entire day", + "more": "more…", "repeat": { "repeat": "Repeat", "end": "End", diff --git a/src/assets/locales/git.json b/src/assets/locales/git.json index 9cfbe2f..937b817 100644 --- a/src/assets/locales/git.json +++ b/src/assets/locales/git.json @@ -3,7 +3,8 @@ "new": "New Commit", "title": "Message", "description": "Message \\n", - "location": "Origin" + "location": "Origin", + "more": "more…" }, "saveBtn": "Commit", "createBtn": "Init / Clone", 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 @@ @@ -51,12 +66,10 @@ function jumpToToday() { - diff --git a/src/components/monthview/MonthEvent.vue b/src/components/monthview/MonthEvent.vue new file mode 100644 index 0000000..78af4a6 --- /dev/null +++ b/src/components/monthview/MonthEvent.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/components/monthview/MonthView.vue b/src/components/monthview/MonthView.vue new file mode 100644 index 0000000..1236e3d --- /dev/null +++ b/src/components/monthview/MonthView.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/components/monthview/MonthWeek.vue b/src/components/monthview/MonthWeek.vue new file mode 100644 index 0000000..4cd6c37 --- /dev/null +++ b/src/components/monthview/MonthWeek.vue @@ -0,0 +1,282 @@ + + + + + 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(() => { 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/composables/useKeyboard.ts b/src/composables/useKeyboard.ts index 64ab50c..de33063 100644 --- a/src/composables/useKeyboard.ts +++ b/src/composables/useKeyboard.ts @@ -66,11 +66,13 @@ export function useKeyboard() { // M -> switch to month view onKeyStroke('m', (e) => { - 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)); diff --git a/src/utils/allDayEventLayout.ts b/src/utils/allDayEventLayout.ts new file mode 100644 index 0000000..4b548d0 --- /dev/null +++ b/src/utils/allDayEventLayout.ts @@ -0,0 +1,81 @@ +import type { DateTime } from 'luxon'; +import type { CalendarEvent } from '@/types/core'; + +export type VisibleDayRange = { + startIndex: number; + endIndex: number; + span: number; +}; + +export type VisibleDayEvent = VisibleDayRange & { + event: CalendarEvent; +}; + +export type LaidOutDayEvent = VisibleDayEvent & { + 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 getVisibleEventsByDay( + events: CalendarEvent[], + viewStart: DateTime, + numberOfDays: number, +): VisibleDayEvent[] { + 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(), + ); + + return visibleEvents; +} + +export function layoutEventsByDay( + events: CalendarEvent[], + viewStart: DateTime, + numberOfDays: number, +): LaidOutDayEvent[] { + const visibleEvents = getVisibleEventsByDay(events, viewStart, numberOfDays); + 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 @@ 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