Merge pull request #13935 from nocodb/nc-fix/cal-support-issues

Nc fix/cal support issues
This commit is contained in:
Ramesh Mane
2026-05-29 17:49:58 +05:30
committed by GitHub
12 changed files with 392 additions and 76 deletions

View File

@@ -44,11 +44,6 @@ const disableToolbar = computed(
isForm.value,
)
const isTab = computed(() => {
if (!isCalendar.value) return false
return width.value > 1200
})
/** EE only: Check if any filters are pinned to the toolbar.
* Hidden for restricted editors in collaborative/locked views — they cannot modify filters.
* Visible for personal view owners — they have full control over view config. */
@@ -144,7 +139,6 @@ const isMobileSearchActive = computed(() => isMobileMode.value && isSearchExpand
</template>
</div>
<SmartsheetToolbarCalendarMode v-if="isCalendar && isTab" :tab="isTab" />
<SmartsheetToolbarRowHeight v-if="(isGrid || isList) && isViewOperationsAllowed && !isMobileMode" />
@@ -176,7 +170,7 @@ const isMobileSearchActive = computed(() => isMobileMode.value && isSearchExpand
<div v-if="isCalendar && isMobileMode" class="flex-1 pointer-events-none" />
<SmartsheetToolbarCalendarMode v-if="isCalendar && !isTab" :tab="isTab" />
<SmartsheetToolbarCalendarMode v-if="isCalendar" :tab="false" />
<SmartsheetToolbarCalendarRange v-if="isCalendar && isViewOperationsAllowed" />

View File

@@ -7,6 +7,7 @@ const emit = defineEmits(['newRecord', 'expandRecord'])
const {
selectedDate,
selectedMonth,
selectedDateRange,
formattedData,
formattedSideBarData,
calDataType,
@@ -19,6 +20,8 @@ const {
updateFormat,
timezoneDayjs,
isSyncedFromColumn,
weeksInRange,
isMultiWeekRange,
} = useCalendarViewStoreOrThrow()
const { isSyncedTable } = useSmartsheetStoreOrThrow()
@@ -32,7 +35,9 @@ const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref())
const maxVisibleDays = computed(() => {
return viewMetaProperties.value?.hide_weekend ? 5 : 7
// Weekend-hiding is only honoured for the calendar month layout — multi-week
// ranges always render the full 7-day grid so weeks line up with the anchor.
return viewMetaProperties.value?.hide_weekend && !isMultiWeekRange.value ? 5 : 7
})
const days = computed(() => {
@@ -92,17 +97,27 @@ const fieldStyles = computed(() => {
const calendarData = computed(() => {
// startOf and endOf dayjs is bugged with timezone
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
const firstDayOffset = isMondayFirst.value ? 0 : -1
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week')).add(firstDayOffset, 'day')
const today = timezoneDayjs.timezonize()
const daysInMonth = startOfMonth.daysInMonth()
const firstDayOfMonth = startOfMonth.day()
let firstDayToDisplay: dayjs.Dayjs
let weeksNeeded: number
const adjustedFirstDay = isMondayFirst.value ? (firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1) : firstDayOfMonth
const weeksNeeded = Math.ceil((daysInMonth + adjustedFirstDay) / 7)
if (isMultiWeekRange.value) {
// 2-week / 6-week grids anchor to the Monday of the selected week and
// always render a fixed number of full weeks. The 6-week range maxes the
// backend's 42-day calendar fetch window exactly — don't extend further
// without also raising the limit in calendar-datas.service.ts.
firstDayToDisplay = timezoneDayjs.timezonize(selectedDateRange.value.start.startOf('week')).add(firstDayOffset, 'day')
weeksNeeded = weeksInRange.value
} else {
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week')).add(firstDayOffset, 'day')
const daysInMonth = startOfMonth.daysInMonth()
const firstDayOfMonth = startOfMonth.day()
const adjustedFirstDay = isMondayFirst.value ? (firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1) : firstDayOfMonth
weeksNeeded = Math.ceil((daysInMonth + adjustedFirstDay) / 7)
}
return {
weeks: Array.from({ length: weeksNeeded }, (_, weekIndex) => ({
@@ -115,7 +130,9 @@ const calendarData = computed(() => {
key: `${weekIndex}-${dayIndex}`,
isWeekend: day.get('day') === 0 || day.get('day') === 6,
isToday: day.isSame(today, 'date'),
isInPagedMonth: day.isSame(selectedMonth.value, 'month'),
// For multi-week grids every cell is "in range" — the layout itself
// is bounded to the visible window, so we don't fade non-month days.
isInPagedMonth: isMultiWeekRange.value ? true : day.isSame(selectedMonth.value, 'month'),
isVisible: maxVisibleDays.value === 5 ? day.get('day') !== 0 && day.get('day') !== 6 : true,
dayNumber: day.format('DD'),
}
@@ -799,6 +816,7 @@ const addRecordWithRange = (range: any, date: dayjs.Dayjs) => {
<div
ref="calendarGridContainer"
:class="{
'grid-rows-2': calendarData.weeks.length === 2,
'grid-rows-5': calendarData.weeks.length === 5,
'grid-rows-6': calendarData.weeks.length === 6,
'grid-rows-7': calendarData.weeks.length === 7,

View File

@@ -109,6 +109,8 @@ const renderData = computed<Array<Row>>(() => {
sideBarFilterOption.value === 'selectedDate' ||
sideBarFilterOption.value === 'selectedHours' ||
sideBarFilterOption.value === 'week' ||
sideBarFilterOption.value === '2week' ||
sideBarFilterOption.value === '6week' ||
sideBarFilterOption.value === 'day'
) {
let fromDate: dayjs.Dayjs | null = null
@@ -128,9 +130,16 @@ const renderData = computed<Array<Row>>(() => {
toDate = timezoneDayjs.dayjsTz(selectedDate.value).endOf('day')
break
case 'week':
case '2week':
case '6week': {
const weeks = sideBarFilterOption.value === '2week' ? 2 : sideBarFilterOption.value === '6week' ? 6 : 1
fromDate = timezoneDayjs.dayjsTz(selectedDateRange.value.start).startOf('week')
toDate = timezoneDayjs.dayjsTz(selectedDateRange.value.end).endOf('week')
toDate = fromDate
.clone()
.add(weeks * 7 - 1, 'day')
.endOf('day')
break
}
case 'day':
fromDate = timezoneDayjs.dayjsTz(selectedDate.value).startOf('day')
toDate = timezoneDayjs.dayjsTz(selectedDate.value).endOf('day')
@@ -170,6 +179,8 @@ const renderData = computed<Array<Row>>(() => {
}
} else if (
sideBarFilterOption.value === 'week' ||
sideBarFilterOption.value === '2week' ||
sideBarFilterOption.value === '6week' ||
sideBarFilterOption.value === 'month' ||
sideBarFilterOption.value === 'year'
) {
@@ -178,9 +189,16 @@ const renderData = computed<Array<Row>>(() => {
switch (sideBarFilterOption.value) {
case 'week':
case '2week':
case '6week': {
const weeks = sideBarFilterOption.value === '2week' ? 2 : sideBarFilterOption.value === '6week' ? 6 : 1
fromDate = timezoneDayjs.dayjsTz(selectedDateRange.value.start).startOf('week')
toDate = timezoneDayjs.dayjsTz(selectedDateRange.value.end).endOf('week')
toDate = fromDate
.clone()
.add(weeks * 7 - 1, 'day')
.endOf('day')
break
}
case 'month':
fromDate = timezoneDayjs.dayjsTz(selectedMonth.value).startOf('month')
toDate = timezoneDayjs.dayjsTz(selectedMonth.value).endOf('month')
@@ -236,6 +254,20 @@ const options = computed(() => {
{ label: 'Without dates', value: 'withoutDates' },
]
}
case '2week' as const:
return [
{ label: 'All records', value: 'allRecords' },
{ label: 'In selected range', value: '2week' },
{ label: 'In selected date', value: 'selectedDate' },
{ label: 'Without dates', value: 'withoutDates' },
]
case '6week' as const:
return [
{ label: 'All records', value: 'allRecords' },
{ label: 'In selected range', value: '6week' },
{ label: 'In selected date', value: 'selectedDate' },
{ label: 'Without dates', value: 'withoutDates' },
]
case 'month' as const:
return [
{ label: 'All records', value: 'allRecords' },
@@ -273,7 +305,11 @@ const newRecord = () => {
let fromDate
if (activeCalendarView.value === 'day') {
fromDate = selectedDate.value
} else if (activeCalendarView.value === 'week') {
} else if (
activeCalendarView.value === 'week' ||
activeCalendarView.value === '2week' ||
activeCalendarView.value === '6week'
) {
fromDate = selectedDateRange.value.start
} else if (activeCalendarView.value === 'month') {
fromDate = selectedDate.value ?? selectedMonth.value

View File

@@ -130,6 +130,13 @@ const calendarData = computed(() => {
const ogStartDate = startDate.clone()
const endDate = fk_to_col && record.row[fk_to_col.title!] ? dayjs(record.row[fk_to_col.title!]) : startDate
// Single-date records with no end column cannot span; if the date sits outside
// the visible week (e.g. backend over-fetched a boundary day), skip rather
// than clamping it onto Monday with a cutoff indicator.
if (!fk_to_col && !isInRange(ogStartDate)) {
return
}
if (startDate.isBefore(selectedDateRange.value.start)) {
startDate = dayjs(selectedDateRange.value.start)
}

View File

@@ -161,7 +161,7 @@ watch(
<LazySmartsheetCalendarYearView v-if="activeCalendarView === 'year'" />
<template v-if="!isCalendarDataLoading">
<LazySmartsheetCalendarMonthView
v-if="activeCalendarView === 'month'"
v-if="activeCalendarView === 'month' || activeCalendarView === '2week' || activeCalendarView === '6week'"
@expand-record="expandRecord"
@new-record="newRecord"
/>

View File

@@ -1,11 +1,31 @@
<script lang="ts" setup>
import type dayjs from 'dayjs'
import { computed } from '#imports'
const { selectedDate, selectedMonth, selectedDateRange, activeCalendarView, activeDates, timezone, pageDate, timezoneDayjs } =
useCalendarViewStoreOrThrow()
const {
selectedDate,
selectedMonth,
selectedDateRange,
activeCalendarView,
activeDates,
timezone,
pageDate,
timezoneDayjs,
weeksInRange,
} = useCalendarViewStoreOrThrow()
const calendarRangeDropdown = ref(false)
const formatWeekRange = (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
if (startDate.isSame(endDate, 'month')) {
return `${startDate.format('D')} - ${endDate.format('D MMM YY')}`
} else if (startDate.isSame(endDate, 'year')) {
return `${startDate.format('D MMM')} - ${endDate.format('D MMM YY')}`
} else {
return `${startDate.format('D MMM YY')} - ${endDate.format('D MMM YY')}`
}
}
const headerText = computed(() => {
switch (activeCalendarView.value) {
case 'day':
@@ -13,13 +33,13 @@ const headerText = computed(() => {
case 'week': {
const startDate = timezoneDayjs.timezonize(selectedDateRange.value.start)
const endDate = timezoneDayjs.timezonize(selectedDateRange.value.end)
if (startDate.isSame(endDate, 'month')) {
return `${startDate.format('D')} - ${endDate.format('D MMM YY')}`
} else if (startDate.isSame(endDate, 'year')) {
return `${startDate.format('D MMM')} - ${endDate.format('D MMM YY')}`
} else {
return `${startDate.format('D MMM YY')} - ${endDate.format('D MMM YY')}`
}
return formatWeekRange(startDate, endDate)
}
case '2week':
case '6week': {
const startDate = timezoneDayjs.timezonize(selectedDateRange.value.start)
const endDate = startDate.add(weeksInRange.value * 7 - 1, 'day')
return formatWeekRange(startDate, endDate)
}
case 'month':
return timezoneDayjs.timezonize(selectedMonth.value).format('MMM YYYY')
@@ -39,7 +59,7 @@ const headerText = computed(() => {
'w-20': activeCalendarView === 'year',
'w-26.5': activeCalendarView === 'month',
'w-29': activeCalendarView === 'day',
'w-38': activeCalendarView === 'week',
'w-38': activeCalendarView === 'week' || activeCalendarView === '2week' || activeCalendarView === '6week',
}"
class="prev-next-btn !h-7"
full-width
@@ -49,7 +69,8 @@ const headerText = computed(() => {
<div class="flex w-full px-1 items-center justify-between">
<span
:class="{
'max-w-38 truncate': activeCalendarView === 'week',
'max-w-38 truncate':
activeCalendarView === 'week' || activeCalendarView === '2week' || activeCalendarView === '6week',
}"
class="font-bold text-[13px] text-center text-nc-content-gray"
data-testid="nc-calendar-active-date"
@@ -72,7 +93,11 @@ const headerText = computed(() => {
size="medium"
/>
<NcDateWeekSelector
v-else-if="activeCalendarView === ('week' as const)"
v-else-if="
activeCalendarView === ('week' as const) ||
activeCalendarView === ('2week' as const) ||
activeCalendarView === ('6week' as const)
"
v-model:active-dates="activeDates"
v-model:page-date="pageDate"
v-model:selected-week="selectedDateRange"

View File

@@ -9,13 +9,21 @@ const isTab = computed(() => props.tab)
const highlightStyle = ref({ left: '0px' })
const setActiveCalendarMode = (mode: 'day' | 'week' | 'month' | 'year', event: MouseEvent) => {
const setActiveCalendarMode = (mode: 'day' | 'week' | '2week' | 'month' | '6week' | 'year', event: MouseEvent) => {
changeCalendarView(mode)
const tabElement = event.target as HTMLElement
highlightStyle.value.left = `${tabElement.offsetLeft}px`
highlightStyle.value.width = `${tabElement.offsetWidth}px`
}
const modeI18nKey = (mode: string) => {
if (mode === '2week') return 'objects.twoWeek'
if (mode === '6week') return 'objects.sixWeek'
return `objects.${mode}`
}
const modes: Array<'day' | 'week' | '2week' | 'month' | '6week' | 'year'> = ['day', 'week', '2week', 'month', '6week', 'year']
const updateHighlightPosition = () => {
nextTick(() => {
const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement
@@ -49,7 +57,7 @@ watch(activeCalendarView, () => {
></div>
<div
v-for="mode in ['day', 'week', 'month', 'year']"
v-for="mode in modes"
:key="mode"
:data-testid="`nc-calendar-view-mode-${mode}`"
class="cursor-pointer tab transition-all px-1 duration-300 flex items-center h-10 z-10 justify-center"
@@ -59,33 +67,47 @@ watch(activeCalendarView, () => {
}"
@click="setActiveCalendarMode(mode, $event)"
>
<div class="min-w-0 pointer-events-none px-2 leading-[18px] text-[13px] transition-all duration-300">
{{ $t(`objects.${mode}`) }}
<div class="min-w-0 pointer-events-none px-2 leading-[18px] text-[13px] transition-all duration-300 whitespace-nowrap">
{{ $t(modeI18nKey(mode)) }}
</div>
</div>
</div>
</div>
</div>
<!--
`option-label-prop="label"` makes the SELECTED chip render just the plain
`label` string (not a clone of the full option template). Without it antd
duplicates the `justify-between` + check-icon row into the chip, leaving
the label visually off-centre and the wrong size.
`:value` + `@change` (not `v-model`) routes selection through
`changeCalendarView` so the view-meta default also gets persisted.
-->
<a-select
v-else
v-model:value="activeCalendarView"
class="nc-select-shadow !w-21 !rounded-lg"
dropdown-class-name="!rounded-lg !min-w-25"
:value="activeCalendarView"
class="nc-select-shadow !w-24 !rounded-lg"
dropdown-class-name="!rounded-lg !min-w-28"
size="small"
option-label-prop="label"
data-testid="nc-calendar-view-mode"
@change="(value) => changeCalendarView(value as typeof modes[number])"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-nc-content-gray-subtle" /></template>
<a-select-option v-for="option in ['day', 'week', 'month', 'year']" :key="option" :value="option">
<div class="w-full flex gap-2 items-center justify-between" :title="option">
<a-select-option v-for="option in modes" :key="option" :value="option" :label="$t(modeI18nKey(option))">
<div
class="w-full flex gap-2 items-center justify-between text-[13px]"
:data-testid="`nc-calendar-view-mode-option-${option}`"
:title="$t(modeI18nKey(option))"
>
<div class="flex items-center gap-1">
<NcTooltip class="flex-1 capitalize mt-0.5 truncate" show-on-truncate-only>
<NcTooltip class="flex-1 mt-0.5 truncate text-[13px]" show-on-truncate-only>
<template #title>
{{ option }}
{{ $t(modeI18nKey(option)) }}
</template>
<template #default>{{ option }}</template>
<template #default>{{ $t(modeI18nKey(option)) }}</template>
</NcTooltip>
</div>
<GeneralIcon
@@ -113,5 +135,13 @@ watch(activeCalendarView, () => {
:deep(.ant-select-selector) {
@apply !h-7;
// With option-label-prop="label" the chip renders just the plain label.
// antd's default line-height is shorter than our 28px chip, so the text
// hugs the top — match the line-height to the chip height to centre it.
.ant-select-selection-item {
@apply !text-[13px] !text-center !font-medium;
line-height: 28px !important;
}
}
</style>

View File

@@ -1,11 +1,30 @@
<script setup lang="ts">
const { activeCalendarView, paginateCalendarView } = useCalendarViewStoreOrThrow()
// Holding Shift while clicking prev/next nudges by exactly 1 week instead of
// the natural per-mode step (1 month for Month, 1 day for Day, 2/6 weeks for
// the multi-week modes). Hidden in Week/Year — the natural step already is
// "one week" / "one year" so a Shift override would have no distinct meaning.
const supportsWeekStep = computed(
() =>
activeCalendarView.value === 'month' ||
activeCalendarView.value === 'day' ||
activeCalendarView.value === '2week' ||
activeCalendarView.value === '6week',
)
const onNavigate = (action: 'next' | 'prev', event: MouseEvent) => {
paginateCalendarView(action, event.shiftKey && supportsWeekStep.value ? 'week' : undefined)
}
</script>
<template>
<div class="flex items-center gap-2">
<NcTooltip hide-on-click>
<template #title> {{ $t('labels.previous') }}</template>
<template #title>
{{ $t('labels.previous') }}
<div v-if="supportsWeekStep" class="text-xs text-nc-content-gray-muted">{{ $t('tooltip.shiftClickWeekStep') }}</div>
</template>
<NcButton
v-e="`['c:calendar:calendar-${activeCalendarView}-prev-btn']`"
@@ -14,13 +33,16 @@ const { activeCalendarView, paginateCalendarView } = useCalendarViewStoreOrThrow
data-testid="nc-calendar-prev-btn"
size="xs"
type="text"
@click="paginateCalendarView('prev')"
@click="onNavigate('prev', $event)"
>
<GeneralIcon icon="ncChevronLeft" class="h-4 !-ml-0.5 w-4" />
</NcButton>
</NcTooltip>
<NcTooltip hide-on-click>
<template #title> {{ $t('labels.next') }}</template>
<template #title>
{{ $t('labels.next') }}
<div v-if="supportsWeekStep" class="text-xs text-nc-content-gray-muted">{{ $t('tooltip.shiftClickWeekStep') }}</div>
</template>
<NcButton
v-e="`['c:calendar:calendar-${activeCalendarView}-next-btn']`"
class="!w-7 !h-7 !rounded-lg !hover:(text-nc-content-gray-subtle) prev-next-btn"
@@ -28,7 +50,7 @@ const { activeCalendarView, paginateCalendarView } = useCalendarViewStoreOrThrow
data-testid="nc-calendar-next-btn"
size="xs"
type="text"
@click="paginateCalendarView('next')"
@click="onNavigate('next', $event)"
>
<GeneralIcon icon="ncChevronRight" class="h-4 !-ml-0.2 w-4" />
</NcButton>

View File

@@ -1,4 +1,5 @@
import type { ComputedRef, Ref } from 'vue'
import { isClient } from '@vueuse/core'
import { EventType, FormulaDataTypes, UITypes, ViewTypes, isSystemColumn, isVirtualCol, workerWithTimezone } from 'nocodb-sdk'
import type {
Api,
@@ -185,13 +186,22 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
// show/hide side menu in calendar
const showSideMenu = ref(!isMobileMode.value)
// reactive ref for the selected date range - used in week view
// reactive ref for the selected date range - used in week / 2week / 6week views.
//
// `start` is always the Monday of the active week. `end` is only meaningful
// for the 1-week layout (Sunday after `start`) and is intentionally NOT
// resized when 2week/6week is active — the WeekView renderer relies on the
// `start + 6 days` semantics, and the multi-week consumers derive their
// true end as `start + weeksInRange*7 - 1` on demand.
//
// `startOf('week')` returns Monday because `dayjs.updateLocale('en', {
// weekStart: 1 })` is set globally in plugins/a.dayjs.ts.
const selectedDateRange = ref<{
start: dayjs.Dayjs
end: dayjs.Dayjs
}>({
start: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week'), // This will be the previous Monday
end: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week').add(6, 'day'), // This will be the following Sunday
start: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week'),
end: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week').add(6, 'day'),
})
const defaultPageSize = 25
@@ -204,7 +214,89 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
const activeDates = ref<dayjs.Dayjs[]>([])
const activeCalendarView = ref<'month' | 'year' | 'day' | 'week'>((viewMetaProperties.value?.active_view as any) ?? 'month')
// Per-view, per-user override for the active mode — kept in localStorage so a
// reload or new tab restores the user's last choice without overwriting the
// view creator's default (`viewMetaProperties.active_view`).
const CALENDAR_MODE_STORAGE_PREFIX = 'nc-calendar-mode:'
const validCalendarModes = ['day', 'week', '2week', 'month', '6week', 'year'] as const
type CalendarMode = (typeof validCalendarModes)[number]
const calendarModeStorageKey = () => (viewMeta.value?.id ? `${CALENDAR_MODE_STORAGE_PREFIX}${viewMeta.value.id}` : null)
const readStoredCalendarMode = (): CalendarMode | null => {
if (!isClient) return null
const key = calendarModeStorageKey()
if (!key) return null
try {
const stored = localStorage.getItem(key)
if (stored && (validCalendarModes as readonly string[]).includes(stored)) {
return stored as CalendarMode
}
} catch {
// localStorage can throw in private mode / when quota exceeded — silent fallback.
}
return null
}
const writeStoredCalendarMode = (mode: CalendarMode) => {
if (!isClient) return
const key = calendarModeStorageKey()
if (!key) return
try {
localStorage.setItem(key, mode)
} catch {
// ignore
}
}
const activeCalendarView = ref<CalendarMode>(
readStoredCalendarMode() ?? (viewMetaProperties.value?.active_view as CalendarMode | undefined) ?? 'month',
)
// On a cold page load `viewMeta.value` (and therefore its `id`) is often
// undefined when the ref above is initialised, so the localStorage lookup
// is skipped. Restore once the view id becomes available.
//
// Keyed by id (not a boolean) so that if the same store instance is reused
// across view switches (eg. <keep-alive>, route param change), the new
// view's stored mode is re-applied instead of leaking the previous view's.
let restoredForCalendarId: string | null = viewMeta.value?.id ?? null
if (restoredForCalendarId && readStoredCalendarMode() === null) {
// viewMeta was ready at construction but storage was empty — nothing to
// restore, mark as handled so the immediate watcher doesn't re-do it.
} else {
restoredForCalendarId = null
}
watch(
() => viewMeta.value?.id,
(id) => {
if (!id || id === restoredForCalendarId) return
const stored = readStoredCalendarMode()
if (stored) {
activeCalendarView.value = stored
} else {
const fromMeta = viewMetaProperties.value?.active_view as CalendarMode | undefined
if (fromMeta && fromMeta !== activeCalendarView.value) {
activeCalendarView.value = fromMeta
}
}
restoredForCalendarId = id
},
{ immediate: true },
)
watch(activeCalendarView, (value) => {
writeStoredCalendarMode(value)
})
// Number of consecutive weeks rendered by the multi-week grid (week / 2week / 6week).
const weeksInRange = computed(() => {
if (activeCalendarView.value === '2week') return 2
if (activeCalendarView.value === '6week') return 6
return 1
})
const isMultiWeekRange = computed(() => activeCalendarView.value === '2week' || activeCalendarView.value === '6week')
// The active filter in the sidebar
const sideBarFilterOption = ref<string>(activeCalendarView.value ?? 'allRecords')
@@ -295,6 +387,8 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
]
} else if (
sideBarFilterOption.value === 'week' ||
sideBarFilterOption.value === '2week' ||
sideBarFilterOption.value === '6week' ||
sideBarFilterOption.value === 'month' ||
sideBarFilterOption.value === 'day' ||
sideBarFilterOption.value === 'year' ||
@@ -315,11 +409,18 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
nextDate = selectedDate.value.add(1, 'day').startOf('day')
break
case 'week':
case '2week':
case '6week': {
const weeks = sideBarFilterOption.value === '2week' ? 2 : sideBarFilterOption.value === '6week' ? 6 : 1
fromDate = selectedDateRange.value.start.startOf('week')
toDate = selectedDateRange.value.end.endOf('week')
toDate = fromDate
.clone()
.add(weeks * 7 - 1, 'day')
.endOf('day')
prevDate = timezoneDayjs.timezonize(fromDate.subtract(1, 'day')).endOf('day')
nextDate = timezoneDayjs.timezonize(toDate.add(1, 'day')).startOf('day')
break
}
case 'month': {
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
@@ -540,7 +641,12 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
let toDate: dayjs.Dayjs | null | string = null
let nextDate: string | null | dayjs.Dayjs = null
if (activeCalendarView.value === 'week' || activeCalendarView.value === 'day') {
if (
activeCalendarView.value === 'week' ||
activeCalendarView.value === 'day' ||
activeCalendarView.value === '2week' ||
activeCalendarView.value === '6week'
) {
const startOfMonth = timezoneDayjs.timezonize(pageDate.value.startOf('month'))
fromDate = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
toDate = timezoneDayjs.timezonize(pageDate.value.endOf('month').endOf('week'))
@@ -600,7 +706,7 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
}
// Update the calendar view
const changeCalendarView = async (view: 'month' | 'year' | 'day' | 'week') => {
const changeCalendarView = async (view: 'month' | 'year' | 'day' | 'week' | '2week' | '6week') => {
$e('c:calendar:change-calendar-view', view)
try {
@@ -615,7 +721,7 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
})
}
if (activeCalendarView.value === 'week') {
if (activeCalendarView.value === 'week' || activeCalendarView.value === '2week' || activeCalendarView.value === '6week') {
selectedTime.value = null
}
} catch (e) {
@@ -654,18 +760,24 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
toDate = selectedDate.value.endOf('day')
break
case 'week':
case '2week':
case '6week': {
fromDate = selectedDateRange.value.start.startOf('week')
toDate = selectedDateRange.value.end.endOf('week')
toDate = fromDate
.clone()
.add(weeksInRange.value * 7 - 1, 'day')
.endOf('day')
prevDate = timezoneDayjs.timezonize(fromDate.subtract(1, 'day')).endOf('day')
nextDate = timezoneDayjs.timezonize(toDate.add(1, 'day')).startOf('day')
// Hide weekends
if (viewMetaProperties.value?.hide_weekend) {
// Hide weekends (only valid for the single-week layout)
if (activeCalendarView.value === 'week' && viewMetaProperties.value?.hide_weekend) {
toDate = timezoneDayjs.timezonize(toDate.subtract(2, 'day')).endOf('day')
nextDate = timezoneDayjs.timezonize(nextDate!.subtract(2, 'day')).startOf('day')
}
break
}
case 'month': {
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
@@ -722,7 +834,38 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
}
}
const paginateCalendarView = async (action: 'next' | 'prev') => {
const paginateCalendarView = async (action: 'next' | 'prev', step?: 'week') => {
// Allow callers to override the natural step (e.g. Shift+Click in Month view
// to step by 1 week instead of 1 month).
if (step === 'week') {
const dayShift = action === 'next' ? 7 : -7
if (activeCalendarView.value === 'month') {
selectedMonth.value = selectedMonth.value.add(dayShift, 'day')
selectedDate.value = selectedDate.value.add(dayShift, 'day')
if (pageDate.value.month() !== selectedMonth.value.month()) {
pageDate.value = selectedMonth.value
}
return
}
if (activeCalendarView.value === 'day') {
selectedDate.value = selectedDate.value.add(dayShift, 'day')
selectedTime.value = selectedDate.value
if (pageDate.value.month() !== selectedDate.value.month()) {
pageDate.value = selectedDate.value
}
return
}
if (activeCalendarView.value === '2week' || activeCalendarView.value === '6week') {
// Shift the multi-week window by exactly 1 week instead of the
// natural 2/6-week step — useful when nudging a 6-week planning grid.
selectedDateRange.value = {
start: selectedDateRange.value.start.add(dayShift, 'day'),
end: selectedDateRange.value.end.add(dayShift, 'day'),
}
return
}
}
switch (activeCalendarView.value) {
case 'month':
selectedMonth.value = action === 'next' ? selectedMonth.value.add(1, 'month') : selectedMonth.value.subtract(1, 'month')
@@ -748,20 +891,25 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
}
break
case 'week':
selectedDateRange.value =
action === 'next'
? {
start: selectedDateRange.value.start.add(7, 'day'),
end: selectedDateRange.value.end.add(7, 'day'),
}
: {
start: selectedDateRange.value.start.subtract(7, 'day'),
end: selectedDateRange.value.end.subtract(7, 'day'),
}
if (pageDate.value.month() !== selectedDateRange.value.end.month()) {
case '2week':
case '6week': {
const dayShift = (action === 'next' ? 1 : -1) * weeksInRange.value * 7
selectedDateRange.value = {
start: selectedDateRange.value.start.add(dayShift, 'day'),
// .end is kept as start + 6 days for back-compat with the WeekView
// renderer; multi-week ranges derive their real end from
// `start + weeksInRange*7 - 1` (see visibleRangeEnd below).
end: selectedDateRange.value.end.add(dayShift, 'day'),
}
// Re-sync pageDate against the LAST visible day of the new window — not
// selectedDateRange.end, which only covers the first week for 2/6-week
// modes and would let pageDate drift up to 5 weeks behind the grid.
const visibleRangeEnd = selectedDateRange.value.start.add(weeksInRange.value * 7 - 1, 'day')
if (pageDate.value.month() !== visibleRangeEnd.month()) {
pageDate.value = selectedDateRange.value.start
}
break
}
}
}
@@ -855,7 +1003,12 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
}
watch(selectedDate, async (value, oldValue) => {
if (activeCalendarView.value === 'month' || activeCalendarView.value === 'week') {
if (
activeCalendarView.value === 'month' ||
activeCalendarView.value === 'week' ||
activeCalendarView.value === '2week' ||
activeCalendarView.value === '6week'
) {
if (sideBarFilterOption.value === 'selectedDate' && showSideMenu.value) {
await loadSidebarData()
}
@@ -890,12 +1043,14 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
})
watch(selectedDateRange, async () => {
if (activeCalendarView.value !== 'week') return
if (activeCalendarView.value !== 'week' && activeCalendarView.value !== '2week' && activeCalendarView.value !== '6week') {
return
}
await Promise.all([loadCalendarData(), loadSidebarData()])
})
watch(activeCalendarView, async (value, oldValue) => {
if (oldValue === 'week') {
if (oldValue === 'week' || oldValue === '2week' || oldValue === '6week') {
pageDate.value = selectedDate.value
selectedMonth.value = selectedDate.value ?? selectedDateRange.value.start
selectedDate.value = selectedDate.value ?? selectedDateRange.value.start
@@ -1319,6 +1474,8 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
timezoneDayjs,
timezone,
isSyncedFromColumn,
weeksInRange,
isMultiWeekRange,
}
},
)

View File

@@ -703,6 +703,7 @@
"days": "Days",
"week": "Week",
"twoWeek": "2 Weeks",
"sixWeek": "6 Weeks",
"month": "Month",
"quarter": "Quarter",
"sixMonth": "6 Months",
@@ -3120,6 +3121,7 @@
"goBackHome": "Go back home"
},
"tooltip": {
"shiftClickWeekStep": "Hold Shift to step by 1 week",
"milestone": "Milestone",
"allowSyncDescription": "Let other bases sync data from this view",
"enableAllowSyncOnView": "Enable \"Allow sync\" on this view first",

View File

@@ -46,7 +46,7 @@ const isRowInCurrentDateRange = (
id: string
is_readonly: boolean
}>,
activeCalendarView: 'month' | 'year' | 'day' | 'week',
activeCalendarView: 'month' | 'year' | 'day' | 'week' | '2week' | '6week',
selectedDate: dayjs.Dayjs,
selectedDateRange: { start: dayjs.Dayjs; end: dayjs.Dayjs },
selectedMonth: dayjs.Dayjs,
@@ -81,6 +81,16 @@ const isRowInCurrentDateRange = (
viewStartDate = selectedDateRange.start.startOf('week')
viewEndDate = selectedDateRange.end.endOf('week')
break
case '2week':
case '6week': {
const weeks = activeCalendarView === '2week' ? 2 : 6
viewStartDate = selectedDateRange.start.startOf('week')
viewEndDate = viewStartDate
.clone()
.add(weeks * 7 - 1, 'day')
.endOf('day')
break
}
case 'month': {
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.startOf('month'))
viewStartDate = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
@@ -162,6 +172,17 @@ const isRowMatchingSidebarFilter = (
timezoneDayjs,
)
case '2week':
case '6week': {
const weeks = sideBarFilterOption === '2week' ? 2 : 6
const start = selectedDateRange.start.startOf('week')
const end = start
.clone()
.add(weeks * 7 - 1, 'day')
.endOf('day')
return isRowInDateRange(rowData, start, end, calendarRange, timezoneDayjs)
}
case 'month': {
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.startOf('month'))
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week'))

View File

@@ -248,6 +248,7 @@ export class CalendarDatasService {
context: NcContext,
{
viewId,
from_date,
next_date,
prev_date,
isDate,
@@ -273,7 +274,10 @@ export class CalendarDatasService {
if (isDate) {
const regex = /^\d{4}-\d{2}-\d{2}/;
next_date = next_date.match(regex)?.[0] || next_date;
prev_date = prev_date.match(regex)?.[0] || prev_date;
// Date-only columns lose the time portion when stripped, so `>= prev_date`
// would include the entire day BEFORE the visible range. Use the stripped
// from_date (first day of the range) as the inclusive lower bound instead.
prev_date = from_date.match(regex)?.[0] || from_date;
}
calendarRange?.ranges.forEach((range: CalendarRange) => {