teqo-backup/src/lib/calendarEvent.ts

132 lines
4.7 KiB
TypeScript

import { BAHIA_TIME_ZONE, formatBahiaCalendarDateTime } from '@/lib/campaignTime'
import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical'
import { normalizeAbsoluteHttpUrl } from '@/lib/shareLink'
/**
* S29 — pure builders for the calendar options of an announcement link
* (Google Calendar template URL + downloadable `.ics`). No I/O, no Payload:
* the page and the route handler only feed the configured event data.
*/
export const DEFAULT_EVENT_DURATION_MS = 2 * 60 * 60 * 1000
const EVENT_TITLE_SUFFIX = ' - Jorge Solla 1313'
export type CalendarEventInput = {
title: string
description?: string | null
location?: string | null
url?: string | null
startsAt?: string | null
endsAt?: string | null
}
const formatInstant = (date: Date): string => formatICalDate(date.toISOString())
const formatCalendarInstant = (date: Date): string => formatBahiaCalendarDateTime(date)
const resolveEventUrl = (url: string | null | undefined): string | null =>
normalizeAbsoluteHttpUrl(url)
const resolveCalendarEventContent = (event: CalendarEventInput) => {
const title = event.title.trim()
const url = resolveEventUrl(event.url)
const descriptionParts: string[] = []
const configuredDescription = event.description
if (configuredDescription?.trim()) descriptionParts.push(configuredDescription)
if (url) descriptionParts.push(`Página do evento: ${url}`)
return {
title: title.endsWith(EVENT_TITLE_SUFFIX) ? title : `${title}${EVENT_TITLE_SUFFIX}`,
description: descriptionParts.length > 0 ? descriptionParts.join('\n\n') : null,
url,
}
}
/**
* The event window: no `startsAt` (or an invalid one) means "no event" — the
* calendar options hide; an absent/invalid `endsAt` or one that does not move
* forward falls back to the standard 2-hour duration. Never a broken/empty
* file: callers get `null` and drop the option entirely.
*/
export const resolveCalendarEventWindow = (
startsAt?: string | null,
endsAt?: string | null,
): { start: Date; end: Date } | null => {
if (!startsAt) return null
const start = new Date(startsAt)
if (Number.isNaN(start.getTime())) return null
const end = endsAt ? new Date(endsAt) : null
if (!end || Number.isNaN(end.getTime()) || end.getTime() <= start.getTime()) {
return { start, end: new Date(start.getTime() + DEFAULT_EVENT_DURATION_MS) }
}
return { start, end }
}
/** `https://calendar.google.com/calendar/render?action=TEMPLATE…` or null without a valid start. */
export const buildGoogleCalendarEventUrl = (event: CalendarEventInput): string | null => {
const window = resolveCalendarEventWindow(event.startsAt, event.endsAt)
if (!window) return null
const content = resolveCalendarEventContent(event)
const params = new URLSearchParams({
action: 'TEMPLATE',
text: content.title,
dates: `${formatCalendarInstant(window.start)}/${formatCalendarInstant(window.end)}`,
ctz: BAHIA_TIME_ZONE,
})
if (content.description) params.set('details', content.description)
if (event.location) params.set('location', event.location)
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
/**
* A single-event VCALENDAR (CRLF, RFC 5545) for the `.ics` download; null
* without a valid start so the route can answer 404 instead of an empty file.
*/
export const buildCalendarEventIcs = ({
uid,
updatedAt,
...event
}: CalendarEventInput & { uid: string; updatedAt?: string | null }): string | null => {
const window = resolveCalendarEventWindow(event.startsAt, event.endsAt)
if (!window) return null
const content = resolveCalendarEventContent(event)
const updated = updatedAt ? new Date(updatedAt) : null
const dtstamp = !updated || Number.isNaN(updated.getTime()) ? window.start : updated
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Teqo//Link de compartilhamento//PT',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VTIMEZONE',
`TZID:${BAHIA_TIME_ZONE}`,
`X-LIC-LOCATION:${BAHIA_TIME_ZONE}`,
'BEGIN:STANDARD',
'DTSTART:19700101T000000',
'TZOFFSETFROM:-0300',
'TZOFFSETTO:-0300',
'TZNAME:-03',
'END:STANDARD',
'END:VTIMEZONE',
'BEGIN:VEVENT',
`UID:${escapeICalText(uid)}`,
`DTSTAMP:${formatInstant(dtstamp)}`,
`DTSTART;TZID=${BAHIA_TIME_ZONE}:${formatCalendarInstant(window.start)}`,
`DTEND;TZID=${BAHIA_TIME_ZONE}:${formatCalendarInstant(window.end)}`,
`SUMMARY:${escapeICalText(content.title)}`,
]
if (content.description) lines.push(`DESCRIPTION:${escapeICalText(content.description)}`)
if (content.url) lines.push(`URL:${content.url}`)
if (event.location) lines.push(`LOCATION:${escapeICalText(event.location)}`)
lines.push('END:VEVENT', 'END:VCALENDAR')
return lines.map(foldICalLine).join('\r\n')
}