mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-17 04:51:18 +01:00
393 lines
10 KiB
TypeScript
393 lines
10 KiB
TypeScript
import type {
|
|
Reminder,
|
|
ReminderOccurrence,
|
|
ReminderOccurrenceOverride,
|
|
ReminderOccurrencePatch,
|
|
ReminderRecurrence,
|
|
ReminderWeekday,
|
|
} from "./types.ts"
|
|
|
|
import { ReminderRecurrenceFrequency } from "./types.ts"
|
|
|
|
interface ZonedDateTimeParts {
|
|
year: number
|
|
month: number
|
|
day: number
|
|
hour: number
|
|
minute: number
|
|
second: number
|
|
millisecond: number
|
|
}
|
|
|
|
interface ExpandReminderOccurrencesOptions {
|
|
from: Date
|
|
to: Date
|
|
includeCompleted: boolean
|
|
overrides?: readonly ReminderOccurrenceOverride[]
|
|
}
|
|
|
|
export function createReminderOccurrenceId(originalDueAt: Date): string {
|
|
return originalDueAt.toISOString()
|
|
}
|
|
|
|
export function expandReminderOccurrences(
|
|
reminder: Reminder,
|
|
options: ExpandReminderOccurrencesOptions,
|
|
): ReminderOccurrence[] {
|
|
const originalDueAts = new Map<string, Date>()
|
|
for (const dueAt of expandReminderOriginalDueAts(reminder, options.from, options.to)) {
|
|
originalDueAts.set(createReminderOccurrenceId(dueAt), dueAt)
|
|
}
|
|
|
|
const overrideById = new Map<string, ReminderOccurrenceOverride>()
|
|
for (const override of options.overrides ?? []) {
|
|
if (override.reminderId !== reminder.id) continue
|
|
if (!isCurrentOriginalDueAt(reminder, override.originalDueAt)) continue
|
|
overrideById.set(override.occurrenceId, override)
|
|
originalDueAts.set(override.occurrenceId, override.originalDueAt)
|
|
}
|
|
|
|
const occurrences: ReminderOccurrence[] = []
|
|
const originals = Array.from(originalDueAts.values()).sort(compareDates)
|
|
|
|
for (const originalDueAt of originals) {
|
|
const occurrenceId = createReminderOccurrenceId(originalDueAt)
|
|
const override = overrideById.get(occurrenceId)
|
|
if (override?.deletedAt) continue
|
|
|
|
const occurrence = createOccurrence(reminder, originalDueAt, override)
|
|
if (occurrence.dueAt < options.from || occurrence.dueAt > options.to) continue
|
|
if (!options.includeCompleted && occurrence.completedAt) continue
|
|
|
|
occurrences.push(occurrence)
|
|
}
|
|
|
|
return occurrences.sort(compareOccurrences)
|
|
}
|
|
|
|
export function expandReminderOriginalDueAts(reminder: Reminder, from: Date, to: Date): Date[] {
|
|
if (to < reminder.dueAt) return []
|
|
|
|
if (!reminder.recurrence) {
|
|
return reminder.dueAt >= from && reminder.dueAt <= to ? [reminder.dueAt] : []
|
|
}
|
|
|
|
switch (reminder.recurrence.frequency) {
|
|
case ReminderRecurrenceFrequency.Daily:
|
|
return expandDaily(reminder, from, to)
|
|
case ReminderRecurrenceFrequency.Weekly:
|
|
return expandWeekly(reminder, from, to)
|
|
case ReminderRecurrenceFrequency.Monthly:
|
|
return expandMonthly(reminder, from, to)
|
|
case ReminderRecurrenceFrequency.Yearly:
|
|
return expandYearly(reminder, from, to)
|
|
}
|
|
}
|
|
|
|
export function findReminderOccurrenceIndex(
|
|
reminder: Reminder,
|
|
occurrenceDueAt: Date,
|
|
): number | null {
|
|
if (!reminder.recurrence) {
|
|
return reminder.dueAt.getTime() === occurrenceDueAt.getTime() ? 0 : null
|
|
}
|
|
|
|
const originals = expandReminderOriginalDueAts(reminder, reminder.dueAt, occurrenceDueAt)
|
|
for (let index = 0; index < originals.length; index++) {
|
|
if (originals[index]!.getTime() === occurrenceDueAt.getTime()) {
|
|
return index
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function isCurrentOriginalDueAt(reminder: Reminder, originalDueAt: Date): boolean {
|
|
return findReminderOccurrenceIndex(reminder, originalDueAt) !== null
|
|
}
|
|
|
|
export function stopRecurrenceAfterOccurrenceCount(
|
|
recurrence: ReminderRecurrence,
|
|
count: number,
|
|
): ReminderRecurrence | null {
|
|
if (count <= 0) return null
|
|
return { ...recurrence, count }
|
|
}
|
|
|
|
export function recurrenceAfterSplit(
|
|
recurrence: ReminderRecurrence,
|
|
occurrenceIndex: number,
|
|
): ReminderRecurrence | null {
|
|
if (recurrence.count === undefined) {
|
|
return { ...recurrence }
|
|
}
|
|
|
|
const remainingCount = recurrence.count - occurrenceIndex
|
|
if (remainingCount <= 1) return null
|
|
|
|
return { ...recurrence, count: remainingCount }
|
|
}
|
|
|
|
function expandDaily(reminder: Reminder, from: Date, to: Date): Date[] {
|
|
return expandStepped(reminder, from, to, function addDaily(parts, step) {
|
|
return addDays(parts, step)
|
|
})
|
|
}
|
|
|
|
function expandMonthly(reminder: Reminder, from: Date, to: Date): Date[] {
|
|
const anchor = getZonedParts(reminder.dueAt, reminder.timeZone).day
|
|
return expandStepped(reminder, from, to, function addMonthly(parts, step) {
|
|
return addMonths(parts, step, anchor)
|
|
})
|
|
}
|
|
|
|
function expandYearly(reminder: Reminder, from: Date, to: Date): Date[] {
|
|
const anchor = getZonedParts(reminder.dueAt, reminder.timeZone).day
|
|
return expandStepped(reminder, from, to, function addYearly(parts, step) {
|
|
return addMonths(parts, step * 12, anchor)
|
|
})
|
|
}
|
|
|
|
function expandStepped(
|
|
reminder: Reminder,
|
|
from: Date,
|
|
to: Date,
|
|
addStep: (parts: ZonedDateTimeParts, step: number) => ZonedDateTimeParts,
|
|
): Date[] {
|
|
const recurrence = reminder.recurrence
|
|
if (!recurrence) return []
|
|
|
|
const dates: Date[] = []
|
|
const start = getZonedParts(reminder.dueAt, reminder.timeZone)
|
|
let emitted = 0
|
|
let index = 0
|
|
|
|
while (true) {
|
|
const parts = addStep(start, index * recurrence.interval)
|
|
const dueAt = zonedPartsToDate(parts, reminder.timeZone)
|
|
if (isAfterRecurrenceEnd(dueAt, recurrence, emitted)) break
|
|
if (dueAt > to) break
|
|
|
|
if (dueAt >= from) {
|
|
dates.push(dueAt)
|
|
}
|
|
|
|
emitted++
|
|
index++
|
|
}
|
|
|
|
return dates
|
|
}
|
|
|
|
function expandWeekly(reminder: Reminder, from: Date, to: Date): Date[] {
|
|
const recurrence = reminder.recurrence
|
|
if (!recurrence) return []
|
|
|
|
const start = getZonedParts(reminder.dueAt, reminder.timeZone)
|
|
const startWeekday = weekdayForParts(start)
|
|
const weekStart = addDays(start, -startWeekday)
|
|
const weekdays = recurrence.weekdays?.length
|
|
? Array.from(new Set(recurrence.weekdays)).sort(compareNumbers)
|
|
: [startWeekday as ReminderWeekday]
|
|
|
|
const dates: Date[] = []
|
|
let emitted = 0
|
|
let weekIndex = 0
|
|
|
|
while (true) {
|
|
let weekHadFutureDate = false
|
|
|
|
for (const weekday of weekdays) {
|
|
const parts = addDays(weekStart, weekIndex * recurrence.interval * 7 + weekday)
|
|
const dueAt = zonedPartsToDate(parts, reminder.timeZone)
|
|
if (dueAt < reminder.dueAt) continue
|
|
if (isAfterRecurrenceEnd(dueAt, recurrence, emitted)) return dates
|
|
if (dueAt > to) {
|
|
weekHadFutureDate = true
|
|
continue
|
|
}
|
|
|
|
if (dueAt >= from) {
|
|
dates.push(dueAt)
|
|
}
|
|
|
|
emitted++
|
|
}
|
|
|
|
if (weekHadFutureDate) break
|
|
weekIndex++
|
|
}
|
|
|
|
return dates.sort(compareDates)
|
|
}
|
|
|
|
function createOccurrence(
|
|
reminder: Reminder,
|
|
originalDueAt: Date,
|
|
override: ReminderOccurrenceOverride | undefined,
|
|
): ReminderOccurrence {
|
|
const patch = override?.patch
|
|
|
|
return {
|
|
reminderId: reminder.id,
|
|
occurrenceId: createReminderOccurrenceId(originalDueAt),
|
|
title: patch?.title ?? reminder.title,
|
|
notes: valueWithNullableOverride(reminder.notes, patch, "notes"),
|
|
originalDueAt,
|
|
dueAt: patch?.dueAt ?? originalDueAt,
|
|
timeZone: patch?.timeZone ?? reminder.timeZone,
|
|
recurrence: reminder.recurrence,
|
|
priority: patch?.priority ?? reminder.priority,
|
|
completedAt: override?.completedAt ?? null,
|
|
}
|
|
}
|
|
|
|
function valueWithNullableOverride(
|
|
fallback: string | null,
|
|
patch: ReminderOccurrencePatch | undefined,
|
|
key: "notes",
|
|
): string | null {
|
|
if (!patch) return fallback
|
|
if (Object.prototype.hasOwnProperty.call(patch, key)) {
|
|
return patch[key] ?? null
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
function isAfterRecurrenceEnd(
|
|
dueAt: Date,
|
|
recurrence: ReminderRecurrence,
|
|
emittedCount: number,
|
|
): boolean {
|
|
if (recurrence.count !== undefined && emittedCount >= recurrence.count) {
|
|
return true
|
|
}
|
|
if (recurrence.until !== undefined && dueAt > recurrence.until) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function getZonedParts(date: Date, timeZone: string): ZonedDateTimeParts {
|
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
timeZone,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hourCycle: "h23",
|
|
})
|
|
|
|
const parts = formatter.formatToParts(date)
|
|
return {
|
|
year: numberPart(parts, "year"),
|
|
month: numberPart(parts, "month"),
|
|
day: numberPart(parts, "day"),
|
|
hour: numberPart(parts, "hour"),
|
|
minute: numberPart(parts, "minute"),
|
|
second: numberPart(parts, "second"),
|
|
millisecond: date.getUTCMilliseconds(),
|
|
}
|
|
}
|
|
|
|
function zonedPartsToDate(parts: ZonedDateTimeParts, timeZone: string): Date {
|
|
const localAsUtc = Date.UTC(
|
|
parts.year,
|
|
parts.month - 1,
|
|
parts.day,
|
|
parts.hour,
|
|
parts.minute,
|
|
parts.second,
|
|
parts.millisecond,
|
|
)
|
|
let timestamp = localAsUtc
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const offset = getTimeZoneOffsetMs(new Date(timestamp), timeZone)
|
|
const next = localAsUtc - offset
|
|
if (next === timestamp) break
|
|
timestamp = next
|
|
}
|
|
|
|
return new Date(timestamp)
|
|
}
|
|
|
|
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
|
|
const parts = getZonedParts(date, timeZone)
|
|
const zonedAsUtc = Date.UTC(
|
|
parts.year,
|
|
parts.month - 1,
|
|
parts.day,
|
|
parts.hour,
|
|
parts.minute,
|
|
parts.second,
|
|
parts.millisecond,
|
|
)
|
|
|
|
return zonedAsUtc - date.getTime()
|
|
}
|
|
|
|
function numberPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): number {
|
|
const part = parts.find(function matchesType(value) {
|
|
return value.type === type
|
|
})
|
|
if (!part) {
|
|
throw new Error(`Missing ${type} part while formatting zoned date`)
|
|
}
|
|
return Number(part.value)
|
|
}
|
|
|
|
function addDays(parts: ZonedDateTimeParts, days: number): ZonedDateTimeParts {
|
|
const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days))
|
|
return {
|
|
...parts,
|
|
year: date.getUTCFullYear(),
|
|
month: date.getUTCMonth() + 1,
|
|
day: date.getUTCDate(),
|
|
}
|
|
}
|
|
|
|
function addMonths(
|
|
parts: ZonedDateTimeParts,
|
|
months: number,
|
|
anchorDay: number,
|
|
): ZonedDateTimeParts {
|
|
const monthIndex = parts.year * 12 + parts.month - 1 + months
|
|
const year = Math.floor(monthIndex / 12)
|
|
const month = positiveModulo(monthIndex, 12) + 1
|
|
const day = Math.min(anchorDay, daysInMonth(year, month))
|
|
|
|
return {
|
|
...parts,
|
|
year,
|
|
month,
|
|
day,
|
|
}
|
|
}
|
|
|
|
function daysInMonth(year: number, month: number): number {
|
|
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
|
}
|
|
|
|
function weekdayForParts(parts: ZonedDateTimeParts): number {
|
|
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay()
|
|
}
|
|
|
|
function positiveModulo(value: number, divisor: number): number {
|
|
return ((value % divisor) + divisor) % divisor
|
|
}
|
|
|
|
function compareDates(a: Date, b: Date): number {
|
|
return a.getTime() - b.getTime()
|
|
}
|
|
|
|
function compareNumbers(a: number, b: number): number {
|
|
return a - b
|
|
}
|
|
|
|
function compareOccurrences(a: ReminderOccurrence, b: ReminderOccurrence): number {
|
|
return a.dueAt.getTime() - b.dueAt.getTime()
|
|
}
|