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>

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.