platform/plugins/calendar/src/utils.ts
Denis Bykhov 7ea0a05f59
Fix calendar yearly events duplicates (#5874)
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
2024-06-20 23:09:56 +07:00

383 lines
11 KiB
TypeScript

import { Timestamp, generateId } from '@hcengineering/core'
import calendar, { Event, ReccuringEvent, ReccuringInstance, RecurringRule } from '.'
function getInstance (event: ReccuringEvent, date: Timestamp): ReccuringInstance {
const diff = event.dueDate - event.date
return {
...event,
recurringEventId: event.eventId,
date,
dueDate: date + diff,
originalStartTime: date,
_class: calendar.class.ReccuringInstance,
eventId: generateEventId(),
_id: generateId(),
virtual: true
}
}
function generateRecurringValues (
rule: RecurringRule,
startDate: Timestamp,
from: Timestamp,
to: Timestamp
): Timestamp[] {
const values: Timestamp[] = []
const currentDate = new Date(startDate)
switch (rule.freq) {
case 'DAILY':
generateDailyValues(rule, currentDate, values, from, to)
break
case 'WEEKLY':
generateWeeklyValues(rule, currentDate, values, from, to)
break
case 'MONTHLY':
generateMonthlyValues(rule, currentDate, values, from, to)
break
case 'YEARLY':
generateYearlyValues(rule, currentDate, values, from, to)
break
default:
throw new Error('Invalid recurring rule frequency')
}
return values
}
function generateDailyValues (
rule: RecurringRule,
currentDate: Date,
values: Timestamp[],
from: Timestamp,
to: Timestamp
): void {
const { count, endDate, interval } = rule
const { bySetPos } = rule
let i = 0
while (true) {
if (bySetPos == null || bySetPos.includes(getSetPos(currentDate))) {
const res = currentDate.getTime()
if (currentDate.getTime() > to) break
if (endDate != null && currentDate.getTime() > endDate) break
if (res >= from && res <= to) {
values.push(res)
}
i++
}
currentDate.setDate(currentDate.getDate() + (interval ?? 1))
if (count !== undefined && i === count) break
}
}
function generateWeeklyValues (
rule: RecurringRule,
currentDate: Date,
values: Timestamp[],
from: Timestamp,
to: Timestamp
): void {
const { count, endDate, interval } = rule
let { byDay, bySetPos } = rule
let i = 0
if (byDay === undefined) {
byDay = [getWeekday(currentDate)]
}
while (true) {
const next = new Date(currentDate).setDate(currentDate.getDate() + (interval ?? 1) * 7)
const end = new Date(new Date(currentDate).setDate(currentDate.getDate() + 7))
let date = currentDate
while (date < end) {
if (endDate != null && date.getTime() > endDate) return
if (date.getTime() > to) return
if ((byDay == null || matchesByDay(date, byDay)) && (bySetPos == null || bySetPos.includes(getSetPos(date)))) {
const res = date.getTime()
if (res >= from && res <= to) {
values.push(res)
}
i++
}
date = new Date(date.setDate(date.getDate() + 1))
if (count !== undefined && i === count) return
}
currentDate = new Date(next)
}
}
function matchesByDay (date: Date, byDay: string[]): boolean {
const weekday = getWeekday(date)
const dayOfMonth = Math.floor((date.getDate() - 1) / 7) + 1
for (const byDayItem of byDay) {
if (byDayItem === weekday) {
return true
}
const pos = parseInt(byDayItem)
if (isNaN(pos)) continue
if (pos > 0 && dayOfMonth === pos) {
return true
}
if (pos < 0 && dayOfMonth === getNegativePosition(date, weekday, pos)) {
return true
}
}
return false
}
function getNegativePosition (date: Date, weekday: string, pos: number): number | undefined {
const lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
const occurrences = []
for (let day = lastDayOfMonth; day >= 1; day--) {
const tempDate = new Date(date.getFullYear(), date.getMonth(), day)
if (getWeekday(tempDate) === weekday) {
occurrences.push(day)
}
if (occurrences.length === Math.abs(pos)) {
return occurrences.pop()
}
}
throw new Error(`Unable to calculate negative position ${pos}`)
}
function generateMonthlyValues (
rule: RecurringRule,
currentDate: Date,
values: Timestamp[],
from: Timestamp,
to: Timestamp
): void {
const { count, endDate, interval } = rule
let { byDay, byMonthDay, bySetPos } = rule
let i = 0
if (byDay == null && byMonthDay == null) {
byMonthDay = [currentDate.getDate()]
}
while (true) {
const next = new Date(currentDate).setMonth(currentDate.getMonth() + (interval ?? 1))
const end = new Date(new Date(currentDate).setMonth(currentDate.getMonth() + 1))
let date = currentDate
while (date < end) {
if (endDate != null && date.getTime() > endDate) return
if (date.getTime() >= to) return
if (
(byDay == null || matchesByDay(date, byDay)) &&
(byMonthDay == null || byMonthDay.includes(new Date(currentDate).getDate())) &&
(bySetPos == null || bySetPos.includes(getSetPos(currentDate)))
) {
const res = currentDate.getTime()
if (res >= from && res <= to) {
values.push(res)
}
i++
}
date = new Date(date.setDate(date.getDate() + 1))
if (count !== undefined && i === count) return
}
currentDate = new Date(next)
}
}
function generateYearlyValues (
rule: RecurringRule,
currentDate: Date,
values: Timestamp[],
from: Timestamp,
to: Timestamp
): void {
const { count, endDate, interval } = rule
const { byDay, byMonthDay, byYearDay, byWeekNo, byMonth, bySetPos } = rule
let i = 0
while (true) {
const next = new Date(currentDate).setFullYear(currentDate.getFullYear() + (interval ?? 1))
const end = new Date(new Date(currentDate).setFullYear(currentDate.getFullYear() + 1))
let date = currentDate
while (date < end) {
if (endDate != null && date.getTime() > endDate) return
if (date.getTime() > to) return
if (
byDay == null &&
byMonthDay == null &&
byYearDay == null &&
byWeekNo == null &&
byMonth == null &&
bySetPos == null
) {
date = new Date(next)
const res = currentDate.getTime()
if (res >= from && res <= to) {
values.push(res)
}
i++
} else {
if (
(byDay == null || matchesByDay(date, byDay)) &&
(byMonthDay == null || byMonthDay.includes(currentDate.getDate())) &&
(byYearDay == null || byYearDay.includes(getYearDay(currentDate))) &&
(byWeekNo == null || byWeekNo.includes(getWeekNumber(currentDate))) &&
(byMonth == null || byMonth.includes(currentDate.getMonth())) &&
(bySetPos == null || bySetPos.includes(getSetPos(currentDate)))
) {
const res = currentDate.getTime()
if (res >= from && res <= to) {
values.push(res)
}
i++
}
date = new Date(date.setDate(date.getDate() + 1))
}
if (count !== undefined && i === count) return
}
currentDate = new Date(next)
}
}
function getSetPos (date: Date): number {
const month = date.getMonth()
const year = date.getFullYear()
const firstOfMonth = new Date(year, month, 1)
const daysOffset = firstOfMonth.getDay()
const day = date.getDate()
return Math.ceil((day + daysOffset) / 7)
}
/**
* @public
*/
export function getWeekday (date: Date): string {
const weekdays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
const weekday = weekdays[date.getDay()]
return weekday
}
function getWeekNumber (date: Date): number {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1)
const daysOffset = firstDayOfYear.getDay()
const diff = (date.getTime() - firstDayOfYear.getTime()) / 86400000
return Math.floor((diff + daysOffset + 1) / 7)
}
function getYearDay (date: Date): number {
const startOfYear = new Date(date.getFullYear(), 0, 0)
const diff = date.getTime() - startOfYear.getTime()
return Math.floor(diff / 86400000)
}
function getReccuringEventInstances (
event: ReccuringEvent,
instances: ReccuringInstance[],
from: Timestamp,
to: Timestamp
): ReccuringInstance[] {
let res: ReccuringInstance[] = []
for (const rule of event.rules ?? []) {
const values = generateRecurringValues(rule, event.date, from, to)
for (const val of values) {
const instance = getInstance(event, val)
res.push(instance)
}
}
for (const date of event.rdate ?? []) {
if (date < from || date > to) continue
const instance = getInstance(event, date)
const exists = res.find((p) => p.date === instance.date)
if (exists === undefined) res.push(instance)
}
const excludes = new Set(event.exdate ?? [])
res = res.filter((p) => !excludes.has(p.date))
res = res.filter((i) => {
const override = instances.find((p) => p.originalStartTime === i.originalStartTime)
return override === undefined
})
return res
}
/**
* @public
*/
export function getAllEvents (events: Event[], from: Timestamp, to: Timestamp): Event[] {
const base: Event[] = []
const recur: ReccuringEvent[] = []
const instances: ReccuringInstance[] = []
const recurData: ReccuringInstance[] = []
const instancesMap = new Map<string, ReccuringInstance[]>()
for (const event of events) {
if (event._class === calendar.class.ReccuringEvent) {
recur.push(event as ReccuringEvent)
} else if (event._class === calendar.class.ReccuringInstance) {
const instance = event as ReccuringInstance
instances.push(instance)
const arr = instancesMap.get(instance.recurringEventId) ?? []
arr.push(instance)
instancesMap.set(instance.recurringEventId, arr)
} else {
if (from >= event.dueDate) continue
if (event.date >= to) continue
base.push(event)
}
}
for (const rec of recur) {
const recEvents = getReccuringEventInstances(rec, instancesMap.get(rec.eventId) ?? [], from, to)
recurData.push(...recEvents)
}
const res = [
...base,
...recurData,
...instances.filter((p) => {
return from <= p.dueDate && p.date <= to && p.isCancelled !== true
})
].map((it) => ({ ...it, date: Math.max(from, it.date), dueDate: Math.min(to, it.dueDate) }))
res.sort((a, b) => a.date - b.date)
return res
}
/**
* @public
*/
export function generateEventId (): string {
const id = generateId()
return encodeToBase32Hex(id)
}
function encodeToBase32Hex (input: string): string {
const encoder = new TextEncoder()
const bytes = encoder.encode(input)
const base32HexDigits = '0123456789abcdefghijklmnopqrstuv'
let result = ''
for (let i = 0; i < bytes.length; i += 5) {
const octets = [
(bytes[i] ?? 0) >> 3,
((bytes[i] & 0x07) << 2) | (bytes[i + 1] >> 6),
(bytes[i + 1] >> 1) & 0x1f,
((bytes[i + 1] & 0x01) << 4) | (bytes[i + 2] >> 4),
((bytes[i + 2] & 0x0f) << 1) | (bytes[i + 3] >> 7),
(bytes[i + 3] >> 2) & 0x1f,
((bytes[i + 3] & 0x03) << 3) | (bytes[i + 4] >> 5),
bytes[i + 4] & 0x1f
]
for (let j = 0; j < 8; j++) {
result += base32HexDigits.charAt(octets[j])
}
}
return result
}