diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-10 22:47:46 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-10 22:47:46 +0900 |
| commit | 05d2b35a85a46dde9a1264d3002ba86e02e3d5eb (patch) | |
| tree | f7722156b80b7c9d7518b05f5ce4bed2e048ef16 /app/routes | |
| parent | c7b05e3667a4f8b84b1048cdd851149284d4926d (diff) | |
Add calendar export (Google/ICS) and extend scrape window to 65 days
- Event detail page: add Google Calendar link and .ics download button
- New route GET /api/events/:id/calendar.ics returns RFC 5545 iCalendar
- Scrape window extended from 35 → 65 days (~2 months ahead)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/routes')
| -rw-r--r-- | app/routes/api.events.$id.ics.ts | 118 | ||||
| -rw-r--r-- | app/routes/events.$id.tsx | 81 |
2 files changed, 199 insertions, 0 deletions
diff --git a/app/routes/api.events.$id.ics.ts b/app/routes/api.events.$id.ics.ts new file mode 100644 index 0000000..c11f9ff --- /dev/null +++ b/app/routes/api.events.$id.ics.ts @@ -0,0 +1,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` + ); +} diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx index 423cda5..87c5e2d 100644 --- a/app/routes/events.$id.tsx +++ b/app/routes/events.$id.tsx @@ -10,6 +10,55 @@ export async function loader({ params }: Route.LoaderArgs) { return { event }; } +function buildGoogleCalendarUrl(event: Awaited<ReturnType<typeof loader>>["event"]): string { + const dates = (() => { + 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); + const pad = (n: number) => String(n).padStart(2, "0"); + const startStr = `${event.date.replace(/-/g, "")}T${pad(h)}${pad(m)}00`; + const endH = h + 2; + let endStr: string; + if (endH < 24) { + endStr = `${event.date.replace(/-/g, "")}T${pad(endH)}${pad(m)}00`; + } else { + const next = new Date(y, mo - 1, d + 1); + endStr = + `${next.getFullYear()}${pad(next.getMonth() + 1)}${pad(next.getDate())}` + + `T${pad(endH - 24)}${pad(m)}00`; + } + return `${startStr}/${endStr}`; + } + 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 dateStr = event.date.replace(/-/g, ""); + const nextStr = `${next.getFullYear()}${pad(next.getMonth() + 1)}${pad(next.getDate())}`; + return `${dateStr}/${nextStr}`; + })(); + + const details: string[] = []; + if (event.artist) details.push(`出演: ${event.artist}`); + if (event.open_time) details.push(`OPEN: ${event.open_time}`); + if (event.start_time) details.push(`START: ${event.start_time}`); + if (event.price) details.push(`料金: ${event.price}`); + if (event.source_url) details.push(`詳細: ${event.source_url}`); + if (event.ticket_url) details.push(`チケット: ${event.ticket_url}`); + + const location = [event.venue_name, event.venue_area].filter(Boolean).join(" "); + + const params = new URLSearchParams({ + action: "TEMPLATE", + text: event.title, + dates, + location, + ctz: "Asia/Tokyo", + }); + if (details.length > 0) params.set("details", details.join("\n")); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + export default function EventDetail() { const { event } = useLoaderData<typeof loader>(); @@ -99,6 +148,27 @@ export default function EventDetail() { )} </div> + <div className="mt-6 flex flex-col gap-2"> + <p className="text-xs text-gray-500">カレンダーに追加</p> + <div className="flex gap-3 flex-wrap"> + <a + href={buildGoogleCalendarUrl(event)} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-2 rounded-md bg-blue-700/50 px-4 py-2 text-sm font-medium hover:bg-blue-700/80 transition-colors" + > + <CalendarIcon /> Google カレンダー + </a> + <a + href={`/api/events/${event.id}/calendar.ics`} + download + className="inline-flex items-center gap-2 rounded-md bg-gray-700/60 px-4 py-2 text-sm font-medium hover:bg-gray-600/80 transition-colors" + > + <CalendarIcon /> .ics ダウンロード + </a> + </div> + </div> + <p className="mt-10 text-xs text-gray-600"> 最終取得: {event.fetched_at} </p> @@ -108,6 +178,17 @@ export default function EventDetail() { ); } +function CalendarIcon() { + return ( + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> + <line x1="16" y1="2" x2="16" y2="6" /> + <line x1="8" y1="2" x2="8" y2="6" /> + <line x1="3" y1="10" x2="21" y2="10" /> + </svg> + ); +} + function Detail({ label, value }: { label: string; value: string }) { return ( <div className="rounded-lg bg-gray-800/60 p-3"> |
