SkateSpots/src/lib/calendar.ts
shakespeare.diy 1c295e96e2 Improve Create Tour: consistent schedules, calendar export & punk redesign
- 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>
2026-09-09 22:16:16 +07:00

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);
}