Skip to content

Commit 7bd8a15

Browse files
committed
fix: replace generateICalFromProcessed with generateICal in EventDetails component
1 parent 844b7f5 commit 7bd8a15

2 files changed

Lines changed: 71 additions & 188 deletions

File tree

src/features/event/EventDetails.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import {
1212
import { BookmarkIcon as BookmarkSolid } from "@heroicons/react/24/solid";
1313
import type { ProcessedEvent } from "@/types/ht";
1414
import { loadConfBookmarks, toggleBookmark } from "@/lib/utils/storage";
15-
import { generateICalFromProcessed } from "@/lib/utils/cal";
1615
import { formatSessionTime } from "@/lib/utils/dates";
1716
import type { HTConference, HTPerson } from "@/types/db";
1817
import Markdown from "@/components/Markdown";
18+
import generateICal from "@/lib/utils/cal";
1919

2020
export default function EventDetails({
2121
event,
@@ -61,7 +61,7 @@ export default function EventDetails({
6161
};
6262

6363
const handleCalendar = () => {
64-
const ics = generateICalFromProcessed(event, conference);
64+
const ics = generateICal(event, conference);
6565
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
6666
const href = URL.createObjectURL(blob);
6767
const a = document.createElement("a");

src/lib/utils/cal.ts

Lines changed: 69 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,201 +1,84 @@
11
import type { HTConference } from "@/types/db";
22
import type { ProcessedEvent } from "@/types/ht";
33

4-
export type ICalOpts = {
5-
productId?: string; // PRODID (default -//HackerTracker//Schedule//EN)
6-
method?:
7-
| "PUBLISH"
8-
| "REQUEST"
9-
| "CANCEL"
10-
| "ADD"
11-
| "REPLY"
12-
| "COUNTER"
13-
| "DECLINECOUNTER";
14-
now?: Date; // override DTSTAMP
15-
alarmMinutesBefore?: number; // optional single display alarm (>=0)
16-
// Optional URL builder for deep links to content pages
17-
urlForEvent?: (ev: ProcessedEvent, conf: HTConference) => string | undefined;
18-
// Domain used in UID: <eventId>@<domain> (default `${conf.code}.hackertracker`)
19-
uidDomain?: string;
20-
// Optional calendar name/description
21-
name?: string;
22-
description?: string;
23-
};
24-
25-
export function generateICalFromProcessed(
26-
events: ProcessedEvent | ProcessedEvent[],
27-
conference: HTConference,
28-
opts: ICalOpts = {}
29-
): string {
30-
const list = Array.isArray(events) ? events : [events];
31-
32-
const productId = opts.productId ?? "-//HackerTracker//Schedule//EN";
33-
const method = opts.method ?? "PUBLISH";
34-
35-
const body: string[] = [];
36-
body.push("BEGIN:VCALENDAR");
37-
body.push("VERSION:2.0");
38-
body.push(fold("PRODID", productId));
39-
body.push("CALSCALE:GREGORIAN");
40-
body.push(fold("METHOD", method));
41-
if (opts.name) body.push(val("X-WR-CALNAME", escapeText(opts.name)));
42-
if (opts.description)
43-
body.push(val("X-WR-CALDESC", escapeText(opts.description)));
44-
45-
for (const ev of list) {
46-
body.push(buildEventFromProcessed(ev, conference, opts));
47-
}
48-
49-
body.push("END:VCALENDAR");
50-
return body.join(CRLF) + CRLF;
51-
}
52-
53-
// Optional helper to trigger browser download
54-
export function downloadIcs(filename: string, ics: string) {
55-
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
56-
const href = URL.createObjectURL(blob);
57-
const a = document.createElement("a");
58-
a.href = href;
59-
a.download = filename.endsWith(".ics") ? filename : `${filename}.ics`;
60-
document.body.appendChild(a);
61-
a.click();
62-
a.remove();
63-
URL.revokeObjectURL(href);
64-
}
65-
66-
// ------------ Implementation -------------------------------------------------
4+
const BASEURL = "https://hackertracker.app";
5+
const PRODID = "-//hackertracker//web Calendar 1.0//EN";
6+
const MAX_LINE_LEN = 75;
677

68-
const CRLF = "\r\n";
69-
70-
function toDate(d: string | number | Date | null | undefined): Date | null {
71-
if (d == null) return null;
72-
return d instanceof Date ? d : new Date(d);
73-
}
74-
75-
function msFromSeconds(seconds: number | null): number | null {
76-
return seconds == null ? null : seconds * 1000;
77-
}
78-
79-
function ensureStart(ev: ProcessedEvent): Date {
80-
// Prefer explicit begin, else beginTimestampSeconds
81-
const date =
82-
toDate(ev.begin) ??
83-
(msFromSeconds(ev.beginTimestampSeconds)
84-
? new Date(msFromSeconds(ev.beginTimestampSeconds)!)
85-
: null);
86-
if (!date) throw new Error(`Event ${ev.id} has no valid start time`);
87-
return date;
88-
}
89-
90-
function ensureEnd(ev: ProcessedEvent, start: Date): Date {
91-
// Prefer explicit end, else endTimestampSeconds, else +60m default
92-
const fromEndField = toDate(ev.end);
93-
if (fromEndField) return fromEndField;
94-
const fromSeconds = msFromSeconds(ev.endTimestampSeconds);
95-
if (fromSeconds) return new Date(fromSeconds);
96-
return new Date(start.getTime() + 60 * 60 * 1000);
97-
}
8+
/** Escape special chars per RFC 5545 */
9+
const escapeICalText = (text = "") =>
10+
text
11+
.replace(/\\/g, "\\\\")
12+
.replace(/;/g, "\\;")
13+
.replace(/,/g, "\\,")
14+
.replace(/\r?\n/g, "\\n");
9815

99-
function fmtUtc(dt: Date): string {
100-
// YYYYMMDDTHHMMSSZ
16+
/** Format a Date to iCal “YYYYMMDDTHHMMSSZ” in UTC */
17+
export const formatICalDate = (d: Date): string => {
10118
const pad = (n: number) => String(n).padStart(2, "0");
10219
return (
103-
dt.getUTCFullYear().toString() +
104-
pad(dt.getUTCMonth() + 1) +
105-
pad(dt.getUTCDate()) +
20+
d.getUTCFullYear().toString() +
21+
pad(d.getUTCMonth() + 1) +
22+
pad(d.getUTCDate()) +
10623
"T" +
107-
pad(dt.getUTCHours()) +
108-
pad(dt.getUTCMinutes()) +
109-
pad(dt.getUTCSeconds()) +
24+
pad(d.getUTCHours()) +
25+
pad(d.getUTCMinutes()) +
26+
pad(d.getUTCSeconds()) +
11027
"Z"
11128
);
112-
}
113-
114-
function escapeText(v: string): string {
115-
// RFC5545 §3.3.11: escape \ ; , and newline
116-
return v
117-
.replace(/\\/g, "\\\\")
118-
.replace(/;/g, "\\;")
119-
.replace(/,/g, "\\,")
120-
.replace(/\r?\n/g, "\\n");
121-
}
29+
};
12230

123-
// Fold to 75 characters (octet-approx) with continuation lines
124-
function foldLine(line: string): string {
125-
const limit = 75;
126-
if (line.length <= limit) return line;
127-
const out: string[] = [];
128-
for (let i = 0; i < line.length; i += limit) {
129-
const chunk = line.slice(i, i + limit);
130-
out.push(i === 0 ? chunk : " " + chunk);
31+
/** Fold long lines with a space prefix on continuations */
32+
const foldLine = (line: string) => {
33+
if (line.length <= MAX_LINE_LEN) return line;
34+
const pieces: string[] = [];
35+
for (let pos = 0; pos < line.length; pos += MAX_LINE_LEN) {
36+
const chunk = line.slice(pos, pos + MAX_LINE_LEN);
37+
pieces.push(pos === 0 ? chunk : " " + chunk);
13138
}
132-
return out.join(CRLF);
133-
}
134-
135-
function fold(name: string, value: string): string {
136-
return foldLine(`${name}:${value}`);
137-
}
138-
139-
function val(name: string, raw?: string): string {
140-
return raw && raw.length ? fold(name, raw) : "";
141-
}
142-
143-
function buildEventFromProcessed(
144-
ev: ProcessedEvent,
145-
conf: HTConference,
146-
opts: ICalOpts
147-
): string {
148-
const start = ensureStart(ev);
149-
const end = ensureEnd(ev, start);
150-
151-
const dtStamp = fmtUtc(opts.now ?? new Date());
152-
const dtStart = fmtUtc(start);
153-
const dtEnd = fmtUtc(end);
154-
155-
const uidDomain = opts.uidDomain ?? `${conf.code}.hackertracker`;
156-
const uid = `${ev.id}@${uidDomain}`;
157-
158-
const title = ev.title || `Event ${ev.id}`;
159-
const location = ev.location ?? "";
160-
const speakers = ev.speakers ? `Speakers: ${ev.speakers}` : "";
161-
const tags = ev.tags?.length
162-
? `Tags: ${ev.tags.map((t) => t.label).join(", ")}`
163-
: "";
164-
const zone = ev.timeZone || conf.timezone || "";
165-
const url = opts.urlForEvent ? opts.urlForEvent(ev, conf) : undefined;
166-
167-
const descParts = [
168-
conf.name ? `Conference: ${conf.name} (${conf.code})` : undefined,
169-
speakers || undefined,
170-
tags || undefined,
171-
zone ? `Timezone: ${zone}` : undefined,
172-
url ? `Link: ${url}` : undefined,
173-
].filter(Boolean) as string[];
39+
return pieces.join("\r\n");
40+
};
17441

175-
const lines: string[] = [];
176-
lines.push("BEGIN:VEVENT");
177-
lines.push(fold("UID", uid));
178-
lines.push(fold("DTSTAMP", dtStamp));
179-
lines.push(fold("DTSTART", dtStart));
180-
lines.push(fold("DTEND", dtEnd));
181-
lines.push(val("SUMMARY", escapeText(title)));
182-
if (location) lines.push(val("LOCATION", escapeText(location)));
183-
if (descParts.length)
184-
lines.push(val("DESCRIPTION", escapeText(descParts.join("\n"))));
185-
if (url) lines.push(fold("URL", escapeText(url)));
42+
/** Build a plain-text description including speakers */
43+
const buildDescription = (event: ProcessedEvent) => {
44+
const speakers = event.speakers ?? "";
45+
return [event.description, speakers].filter(Boolean).join("\\n");
46+
};
18647

187-
if (
188-
typeof opts.alarmMinutesBefore === "number" &&
189-
Number.isFinite(opts.alarmMinutesBefore)
190-
) {
191-
const mins = Math.max(0, Math.floor(opts.alarmMinutesBefore));
192-
lines.push("BEGIN:VALARM");
193-
lines.push(fold("TRIGGER", `-PT${mins}M`));
194-
lines.push("ACTION:DISPLAY");
195-
lines.push(val("DESCRIPTION", escapeText(title)));
196-
lines.push("END:VALARM");
197-
}
48+
/** Generate a full iCal string for an event */
49+
export const generateICal = (
50+
event: ProcessedEvent,
51+
conference: HTConference
52+
): string => {
53+
const now = new Date();
54+
const dtstamp = formatICalDate(now);
55+
const dtstart = formatICalDate(new Date(event.begin));
56+
const dtend = formatICalDate(new Date(event.end ?? event.begin));
57+
const uid = `${conference.code}-${event.id}@hackertracker.app`;
58+
59+
const lines = [
60+
"BEGIN:VCALENDAR",
61+
"METHOD:PUBLISH",
62+
"VERSION:2.0",
63+
`PRODID:${PRODID}`,
64+
"BEGIN:VEVENT",
65+
`UID:${uid}`,
66+
`SEQUENCE:0`,
67+
`DTSTAMP:${dtstamp}`,
68+
`DTSTART:${dtstart}`,
69+
`DTEND:${dtend}`,
70+
"STATUS:CONFIRMED",
71+
"CATEGORIES:CONFERENCE",
72+
`SUMMARY:${escapeICalText(event.title)}`,
73+
`URL:${BASEURL}/event?conf=${conference.code}&event=${event.id}`,
74+
`LOCATION:${escapeICalText(event.location ?? "")}`,
75+
`DESCRIPTION:${escapeICalText(buildDescription(event))}`,
76+
"END:VEVENT",
77+
"END:VCALENDAR",
78+
];
79+
80+
// fold and join
81+
return lines.map(foldLine).join("\r\n");
82+
};
19883

199-
lines.push("END:VEVENT");
200-
return lines.join(CRLF);
201-
}
84+
export default generateICal;

0 commit comments

Comments
 (0)