06 · Developer Guide · 레시피
교회 일정 캘린더 + ICS 내보내기Church Calendar
주일·새벽·수요예배의 반복 일정과 행사 일회성 일정을 한 캘린더에 모읍니다.
ICS 피드로 내보내면 성도가 본인 핸드폰 캘린더에 구독해 자동 동기화됩니다.
언제 쓰는가
- 예배·기도회 시간을 한곳에서 보고 싶을 때
- 특별 새벽기도회·부흥회·체육대회 등 단발성 행사 안내
- 성도가 본인 핸드폰 캘린더에 교회 일정을 구독하고 싶을 때
① 데이터 스키마 + 시드
반복 일정은 매주 행을 만들지 않고 iCal RRULE 한 줄로 정의. 표시 시점에 펼칩니다.
supabase/migrations/0004_events.sqlsql
-- 교회 일정 테이블
create table public.events (
id bigint generated always as identity primary key,
title text not null,
description text,
location text,
starts_at timestamptz not null,
ends_at timestamptz not null,
all_day boolean not null default false,
rrule text, -- iCal RRULE (예: FREQ=WEEKLY;BYDAY=SU)
category text not null, -- '주일예배·새벽예배·구역모임·교육' 등
visibility text not null default 'public',
created_at timestamptz not null default now()
);
create index events_starts_idx on public.events (starts_at);
-- 자주 쓰는 반복 일정 미리 입력 (시드)
insert into public.events (title, starts_at, ends_at, rrule, category, location)
values
('주일 1부 예배', '2026-01-04 09:00+09', '2026-01-04 10:30+09',
'FREQ=WEEKLY;BYDAY=SU', '주일예배', '본당'),
('주일 2부 예배', '2026-01-04 11:00+09', '2026-01-04 12:30+09',
'FREQ=WEEKLY;BYDAY=SU', '주일예배', '본당'),
('새벽기도회', '2026-01-05 05:30+09', '2026-01-05 06:30+09',
'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA', '새벽예배', '본당'),
('수요예배', '2026-01-07 19:30+09', '2026-01-07 20:30+09',
'FREQ=WEEKLY;BYDAY=WE', '수요예배', '본당');② RRULE 펼치기 — 특정 월에만
lib/calendar/expand.tsts
// lib/calendar/expand.ts — 반복 일정을 특정 월에 펼치기
import { RRule, RRuleSet } from "rrule";
import { addMonths, startOfMonth, endOfMonth } from "date-fns";
export function expandEvents(events: Event[], target: Date) {
const monthStart = startOfMonth(target);
const monthEnd = endOfMonth(target);
const out: ExpandedEvent[] = [];
for (const ev of events) {
if (!ev.rrule) {
// 일회성 일정 — 그대로
if (ev.starts_at >= monthStart && ev.starts_at <= monthEnd) {
out.push({ ...ev, instance_at: ev.starts_at });
}
continue;
}
// 반복 일정 — RRULE을 펼침
const rule = RRule.fromString(`DTSTART:${toICSDate(ev.starts_at)}\n` + ev.rrule);
const occurrences = rule.between(monthStart, monthEnd, true);
for (const at of occurrences) {
out.push({ ...ev, instance_at: at });
}
}
return out.sort((a, b) =>
a.instance_at.getTime() - b.instance_at.getTime()
);
}③ 월간 보기 UI
components/preview/ChurchCalendar.tsxtsx
"use client";
import { useMemo, useState } from "react";
import { addMonths } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { expandEvents } from "@/lib/calendar/expand";
export function ChurchCalendar({ events }: { events: Event[] }) {
const [current, setCurrent] = useState(new Date());
const occurrences = useMemo(() => expandEvents(events, current), [events, current]);
const days = useMemo(() => buildCalendarGrid(current), [current]);
return (
<div>
<header className="flex items-center justify-between">
<h2 className="text-[20px] font-bold">
{current.getFullYear()}년 {current.getMonth() + 1}월
</h2>
<nav className="flex gap-2">
<button onClick={() => setCurrent(addMonths(current, -1))} aria-label="이전 달">
<ChevronLeft size={18} />
</button>
<button onClick={() => setCurrent(new Date())} className="text-[12px]">
오늘
</button>
<button onClick={() => setCurrent(addMonths(current, 1))} aria-label="다음 달">
<ChevronRight size={18} />
</button>
</nav>
</header>
<div className="mt-4 grid grid-cols-7 border-l border-t">
{["일", "월", "화", "수", "목", "금", "토"].map((d) => (
<div key={d} className="border-b border-r bg-cream-50 p-2 text-center text-[12px] font-medium">
{d}
</div>
))}
{days.map((day) => {
const todays = occurrences.filter(
(e) => e.instance_at.toDateString() === day.toDateString(),
);
return (
<div key={day.toISOString()} className="min-h-[110px] border-b border-r p-1.5">
<div className="text-[11px] text-text-muted">{day.getDate()}</div>
<ul className="mt-1 space-y-1">
{todays.map((e) => (
<li
key={`${e.id}-${e.instance_at.toISOString()}`}
className="truncate rounded bg-blue-50 px-1.5 py-0.5 text-[10.5px] text-accent"
title={e.title}
>
{e.title}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
}④ ICS 피드 — 구독 URL
app/calendar.ics/route.tsts
// app/calendar.ics/route.ts — 사용자 캘린더 앱 구독용 ICS 피드
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function GET() {
const supabase = await createClient();
const { data: events } = await supabase
.from("events")
.select("*")
.eq("visibility", "public");
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//주님의교회 PCL//Calendar//KO",
"X-WR-CALNAME:주님의교회 PCL",
"X-WR-TIMEZONE:Asia/Seoul",
];
for (const e of events ?? []) {
lines.push(
"BEGIN:VEVENT",
`UID:${e.id}@pcldesign.kr`,
`DTSTAMP:${toICS(new Date())}`,
`DTSTART:${toICS(new Date(e.starts_at))}`,
`DTEND:${toICS(new Date(e.ends_at))}`,
`SUMMARY:${escapeICS(e.title)}`,
e.location ? `LOCATION:${escapeICS(e.location)}` : "",
e.description ? `DESCRIPTION:${escapeICS(e.description)}` : "",
e.rrule ? `RRULE:${e.rrule}` : "",
"END:VEVENT",
);
}
lines.push("END:VCALENDAR");
return new Response(lines.filter(Boolean).join("\r\n"), {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="pcl-calendar.ics"',
"Cache-Control": "public, max-age=3600",
},
});
}
const toICS = (d: Date) =>
d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
const escapeICS = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,");⑤ 사용자 구독 안내
tsx
// 캘린더 구독 안내 — 사용자가 한 번 등록하면 자동 업데이트
//
// 아이폰 캘린더
// 설정 → 캘린더 → 계정 → 계정 추가 → 기타 → 구독 캘린더 추가
// URL: https://pcldesign.kr/calendar.ics
//
// 구글 캘린더
// 왼쪽 「다른 캘린더」 + → URL로 추가
// URL: https://pcldesign.kr/calendar.ics
//
// 1회 다운로드 (단발성)
<Link href="/calendar.ics" download>
캘린더 파일 다운로드
</Link>