summaryrefslogtreecommitdiff
path: root/app/routes/api.events.$id.ics.ts
blob: c11f9ff56c7c6f29a2e787d748746ef80d515df4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import { getEvent } from "~/lib/db.server";
import type { Route } from "./+types/api.events.$id.ics";
import type { Event } from "~/lib/db.server";

export async function loader({ params }: Route.LoaderArgs) {
  const id = parseInt(params.id, 10);
  if (isNaN(id)) throw new Response("Not Found", { status: 404 });
  const event = getEvent(id);
  if (!event) throw new Response("Not Found", { status: 404 });

  return new Response(buildIcs(event), {
    headers: {
      "Content-Type": "text/calendar; charset=utf-8",
      "Content-Disposition": `attachment; filename="event-${id}.ics"`,
      "Cache-Control": "no-cache",
    },
  });
}

function buildIcs(event: Event): string {
  const uid = `event-${event.id}@tokyo-livehouse-events`;
  const dtstamp = toIcsDateTime(new Date());

  let dtstart: string;
  let dtend: string;

  if (event.start_time && /^\d{1,2}:\d{2}/.test(event.start_time)) {
    const [h, m] = event.start_time.split(":").map(Number);
    const [y, mo, d] = event.date.split("-").map(Number);
    // Floating time (no timezone suffix) — display in user's local time
    const pad = (n: number) => String(n).padStart(2, "0");
    const startStr = `${event.date.replace(/-/g, "")}T${pad(h)}${pad(m)}00`;
    // End time = start + 2 hours
    const endH = h + 2;
    const endStr =
      endH < 24
        ? `${event.date.replace(/-/g, "")}T${pad(endH)}${pad(m)}00`
        : (() => {
            const next = new Date(y, mo - 1, d + 1);
            return (
              `${next.getFullYear()}${pad(next.getMonth() + 1)}${pad(next.getDate())}` +
              `T${pad(endH - 24)}${pad(m)}00`
            );
          })();
    dtstart = `DTSTART:${startStr}`;
    dtend = `DTEND:${endStr}`;
  } else {
    // All-day event
    const [y, mo, d] = event.date.split("-").map(Number);
    const next = new Date(y, mo - 1, d + 1);
    const pad = (n: number) => String(n).padStart(2, "0");
    const nextStr = `${next.getFullYear()}${pad(next.getMonth() + 1)}${pad(next.getDate())}`;
    dtstart = `DTSTART;VALUE=DATE:${event.date.replace(/-/g, "")}`;
    dtend = `DTEND;VALUE=DATE:${nextStr}`;
  }

  const descParts: string[] = [];
  if (event.artist) descParts.push(`出演: ${event.artist}`);
  if (event.open_time) descParts.push(`OPEN: ${event.open_time}`);
  if (event.start_time) descParts.push(`START: ${event.start_time}`);
  if (event.price) descParts.push(`料金: ${event.price}`);
  if (event.source_url) descParts.push(`詳細: ${event.source_url}`);
  if (event.ticket_url) descParts.push(`チケット: ${event.ticket_url}`);

  const location = [event.venue_name, event.venue_area].filter(Boolean).join(" ");

  const lines: string[] = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//Tokyo Livehouse Events//JA",
    "CALSCALE:GREGORIAN",
    "METHOD:PUBLISH",
    "BEGIN:VEVENT",
    `UID:${uid}`,
    `DTSTAMP:${dtstamp}`,
    dtstart,
    dtend,
    foldLine(`SUMMARY:${escIcs(event.title)}`),
    foldLine(`LOCATION:${escIcs(location)}`),
  ];

  if (descParts.length > 0) {
    lines.push(foldLine(`DESCRIPTION:${escIcs(descParts.join("\\n"))}`));
  }

  if (event.source_url) {
    lines.push(`URL:${event.source_url}`);
  }

  lines.push("END:VEVENT", "END:VCALENDAR");

  return lines.join("\r\n") + "\r\n";
}

function escIcs(s: string): string {
  return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,");
}

// Fold lines longer than 75 chars (RFC 5545 §3.1)
function foldLine(line: string): string {
  if (line.length <= 75) return line;
  const chunks: string[] = [];
  let pos = 0;
  while (pos < line.length) {
    const limit = pos === 0 ? 75 : 74;
    chunks.push((pos === 0 ? "" : " ") + line.slice(pos, pos + limit));
    pos += limit;
  }
  return chunks.join("\r\n");
}

function toIcsDateTime(d: Date): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return (
    `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
    `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
  );
}