- Add a unified Schedule section across all four tour categories: - Local: choose a fixed session (date+time) or flexible/by-appointment - National/International: required departure + optional return trip dates - Destruction: existing competition window, now with end>=start validation - Reuse start-date/end-date tags for every category (parser already supported them) - Fix datetime handling: dedicated toDateInput/inputToSecs helpers so date and datetime-local inputs round-trip correctly (previous mix of toLocalInput + type="date" was inconsistent) - Consistent, logical mandatory-field validation with clear required markers - Redesign the header into an immersive, category-tinted, grunge-striped banner and rebuild the category selector as elegant glowing cards - New calendar utilities (.ics + Google Calendar) and AddToCalendar component - TourDetailPage is now fully category-aware (badge, guide label, duration, SEO title) with a Schedule card + Add to Calendar for attendees - TourCard shows the schedule for all categories (time for local sessions) - Update NIP.md to document start-date/end-date across categories Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
/**
|
|
* Lightweight iCalendar (.ics) generation for skate tours & events.
|
|
*
|
|
* No external deps — we build a spec-compliant VEVENT string and hand it to the
|
|
* browser as a downloadable file so attendees can drop a tour straight into
|
|
* Apple Calendar, Google Calendar, Outlook, etc.
|
|
*/
|
|
|
|
export interface CalendarEventInput {
|
|
/** Stable unique id (we use the Nostr addressable coordinate). */
|
|
uid: string;
|
|
title: string;
|
|
description?: string;
|
|
location?: string;
|
|
/** Start time in unix seconds. */
|
|
start: number;
|
|
/** Optional end time in unix seconds. If omitted, defaults to start + 2h. */
|
|
end?: number;
|
|
/** Optional URL to include in the event body. */
|
|
url?: string;
|
|
/** When true, the event is treated as an all-day / multi-day date range. */
|
|
allDay?: boolean;
|
|
}
|
|
|
|
/** Escape text per RFC 5545 (commas, semicolons, backslashes, newlines). */
|
|
function escapeICS(text: string): string {
|
|
return text
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/;/g, '\\;')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/\r?\n/g, '\\n');
|
|
}
|
|
|
|
/** Format unix seconds to a UTC timestamp: YYYYMMDDTHHMMSSZ */
|
|
function toICSDateTime(secs: number): string {
|
|
const d = new Date(secs * 1000);
|
|
return (
|
|
d.getUTCFullYear().toString().padStart(4, '0') +
|
|
(d.getUTCMonth() + 1).toString().padStart(2, '0') +
|
|
d.getUTCDate().toString().padStart(2, '0') +
|
|
'T' +
|
|
d.getUTCHours().toString().padStart(2, '0') +
|
|
d.getUTCMinutes().toString().padStart(2, '0') +
|
|
d.getUTCSeconds().toString().padStart(2, '0') +
|
|
'Z'
|
|
);
|
|
}
|
|
|
|
/** Format unix seconds to an all-day date value: YYYYMMDD (local calendar day). */
|
|
function toICSDate(secs: number): string {
|
|
const d = new Date(secs * 1000);
|
|
return (
|
|
d.getFullYear().toString().padStart(4, '0') +
|
|
(d.getMonth() + 1).toString().padStart(2, '0') +
|
|
d.getDate().toString().padStart(2, '0')
|
|
);
|
|
}
|
|
|
|
/** Build a full VCALENDAR document containing a single VEVENT. */
|
|
export function buildICS(input: CalendarEventInput): string {
|
|
const now = toICSDateTime(Math.floor(Date.now() / 1000));
|
|
|
|
const lines: string[] = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'PRODID:-//SkateSpots//Tours//EN',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:PUBLISH',
|
|
'BEGIN:VEVENT',
|
|
`UID:${escapeICS(input.uid)}@skate-spots.org`,
|
|
`DTSTAMP:${now}`,
|
|
];
|
|
|
|
if (input.allDay) {
|
|
// All-day: DTEND is exclusive, so add one day to the last calendar day.
|
|
const endSecs = input.end ?? input.start;
|
|
lines.push(`DTSTART;VALUE=DATE:${toICSDate(input.start)}`);
|
|
lines.push(`DTEND;VALUE=DATE:${toICSDate(endSecs + 24 * 60 * 60)}`);
|
|
} else {
|
|
const endSecs = input.end ?? input.start + 2 * 60 * 60;
|
|
lines.push(`DTSTART:${toICSDateTime(input.start)}`);
|
|
lines.push(`DTEND:${toICSDateTime(endSecs)}`);
|
|
}
|
|
|
|
lines.push(`SUMMARY:${escapeICS(input.title)}`);
|
|
|
|
const descParts: string[] = [];
|
|
if (input.description) descParts.push(input.description);
|
|
if (input.url) descParts.push(input.url);
|
|
if (descParts.length > 0) {
|
|
lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n\n'))}`);
|
|
}
|
|
|
|
if (input.location) lines.push(`LOCATION:${escapeICS(input.location)}`);
|
|
if (input.url) lines.push(`URL:${escapeICS(input.url)}`);
|
|
|
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
|
|
|
return lines.join('\r\n');
|
|
}
|
|
|
|
/** Trigger a browser download of an .ics file for the given event. */
|
|
export function downloadICS(input: CalendarEventInput, filename?: string): void {
|
|
const ics = buildICS(input);
|
|
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename ?? `${slug(input.title) || 'skate-tour'}.ics`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
// Give the browser a tick to start the download before revoking.
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}
|
|
|
|
/** Build a Google Calendar "add event" URL as a fallback for the web. */
|
|
export function googleCalendarUrl(input: CalendarEventInput): string {
|
|
const fmt = input.allDay ? toICSDate : toICSDateTime;
|
|
const endSecs = input.allDay
|
|
? (input.end ?? input.start) + 24 * 60 * 60
|
|
: input.end ?? input.start + 2 * 60 * 60;
|
|
|
|
const params = new URLSearchParams({
|
|
action: 'TEMPLATE',
|
|
text: input.title,
|
|
dates: `${fmt(input.start)}/${fmt(endSecs)}`,
|
|
});
|
|
if (input.description || input.url) {
|
|
params.set('details', [input.description, input.url].filter(Boolean).join('\n\n'));
|
|
}
|
|
if (input.location) params.set('location', input.location);
|
|
|
|
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
|
}
|
|
|
|
function slug(s: string): string {
|
|
return s
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.slice(0, 50);
|
|
}
|