mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
132 lines
4.7 KiB
TypeScript
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')
|
|
}
|