Skip to content
FunDev
FunDev
fastapi

Full-Stack Calendar Development: Recurring Events with FastAPI and Next.js

Full-Stack Calendar Development: Recurring Events with FastAPI and Next.js
10 views
11 min read
#fastapi

This is a follow-up to Building a Calendar with Claude Code and Superpowers. If you are interested in the AI-assisted development workflow, read the earlier post first.

This post shares the full process of building a family calendar with a FastAPI backend and a Next.js frontend.


Technology Stack

Backend

Python 3.13 + FastAPI 0.115
PostgreSQL + SQLAlchemy 2.0
Firebase Authentication
python-dateutil (반복 일정)
pytest (TDD)

Frontend

Next.js 16.1.1 + React 19
TypeScript 5 (Strict Mode)
Tailwind CSS 4
shadcn/ui + Radix UI
Firebase SDK 12.7
date-fns 4.1 (한국어 로케일)

Authentication is unified through Firebase. Signing in with Firebase on the frontend produces an ID token, which the backend verifies to identify the user.


Full-Stack Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         Frontend                                │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────────────┐  │
│  │  Next.js    │  │  shadcn/ui   │  │     Firebase Auth      │  │
│  │  App Router │  │  Components  │  │  (로그인 + ID Token)     │  │
│  └──────┬──────┘  └──────────────┘  └───────────┬────────────┘  │
│         │                                        │              │
│         │         API Client (Bearer Token)      │              │
│         └───────────────────┬────────────────────┘              │
└─────────────────────────────┼───────────────────────────────────┘
                              │ HTTPS
┌─────────────────────────────┼───────────────────────────────────┐
│                         Backend                                 │
│                              │                                  │
│  ┌───────────────────────────┴────────────────────────────────┐ │
│  │                      FastAPI                               │ │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌────────────┐  │ │
│  │  │  Auth    │  │ Members  │  │Categories│  │   Events   │  │ │
│  │  │  Router  │  │  Router  │  │  Router  │  │   Router   │  │ │
│  │  └────┬─────┘  └────┬─────┘  └────┬─────┘  └─────┬──────┘  │ │
│  │       │             │             │              │         │ │
│  │       └─────────────┴──────┬──────┴──────────────┘         │ │
│  │                            │                               │ │
│  │  ┌─────────────────────────┴─────────────────────────────┐ │ │
│  │  │              Service Layer (Protocol 기반)             │ │ │
│  │  │   MemberService │ CategoryService │ EventService      │ │ │
│  │  └─────────────────────────┬─────────────────────────────┘ │ │
│  │                            │                               │ │
│  │  ┌─────────────────────────┴─────────────────────────────┐ │ │
│  │  │           PostgreSQL + SQLAlchemy ORM                 │ │ │
│  │  │   FamilyMember │ Category │ Event │ RecurrenceException││ │
│  │  └───────────────────────────────────────────────────────┘ │ │
│  └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

Part 1: Backend Implementation

Improving Testability with the Protocol Pattern

I used Python's Protocol to define interfaces and separate the real services from fake implementations.

# protocol.py
class MemberServiceProtocol(Protocol):
    def get_all(self) -> list[FamilyMemberResponse]: ...
    def create(self, data: FamilyMemberCreate) -> FamilyMemberResponse: ...
    def verify_and_link(self, email: str, firebase_uid: str) -> FamilyMemberResponse: ...

This pattern lets 92 tests run without PostgreSQL in just 0.12 seconds.

The Core of Recurring Events: RRULE

RRULE (Recurrence Rule) is the recurrence format defined in the iCalendar standard, RFC 5545.

FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR

This rule means “repeat every Monday, Wednesday, and Friday.” For a user-friendly API, I also support a pattern-object approach.

{
  "title": "학원",
  "start_time": "2024-01-15T16:00:00",
  "end_time": "2024-01-15T18:00:00",
  "recurrence_pattern": {
    "frequency": "WEEKLY",
    "weekdays": ["MO", "WE", "FR"]
  },
  "recurrence_end": "2024-12-31"
}

Expanding Recurring Events

When events are queried, recurring events are expanded into every occurrence within the requested period.

def get_by_date_range(self, start_date: date, end_date: date):
    results = []
 
    # 1. 일반 일정 조회
    non_recurring = self.db.query(Event).filter(
        Event.recurrence_rule.is_(None),
        Event.start_time.between(start_date, end_date),
    ).all()
 
    # 2. 반복 일정 확장
    recurring = self.db.query(Event).filter(
        Event.recurrence_rule.isnot(None),
        Event.start_time <= end_date,
    ).all()
 
    for event in recurring:
        occurrences = get_occurrences(
            rrule_str=event.recurrence_rule,
            dtstart=event.start_time,
            range_start=start_date,
            range_end=end_date,
        )
        for occurrence_date in occurrences:
            results.append(self._event_to_response(event, occurrence_date))
 
    return results

Part 2: Frontend Implementation

Project Structure

src/
├── app/
│   ├── page.tsx          # 메인 캘린더 (인증 필요)
│   └── login/page.tsx    # 로그인 페이지
├── components/
│   ├── calendar/         # 캘린더 컴포넌트
│   │   ├── CalendarHeader.tsx   # 뷰 전환 + 네비게이션
│   │   ├── MonthView.tsx        # 월간 그리드
│   │   ├── WeekView.tsx         # 주간 타임라인
│   │   ├── DayView.tsx          # 일간 상세
│   │   ├── EventDialog.tsx      # 일정 생성/수정
│   │   └── CategoryDialog.tsx   # 카테고리 관리
│   └── ui/               # shadcn/ui 컴포넌트
├── lib/
│   ├── api.ts            # API 클라이언트
│   ├── auth.ts           # Firebase 인증 함수
│   └── firebase.ts       # Firebase 초기화
└── hooks/
    └── useAuth.ts        # 인증 상태 관리 훅

API Client: Automatic Bearer Token Injection

Automatically add the Firebase ID token to every API request.

async function fetchWithAuth(url: string, options: RequestInit = {}) {
  const token = await getIdToken();
 
  const headers = {
    'Content-Type': 'application/json',
    ...(token && { Authorization: `Bearer ${token}` }),
    ...options.headers,
  };
 
  const response = await fetch(`${API_URL}${url}`, {
    ...options,
    headers,
  });
 
  if (!response.ok) {
    const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
    throw new Error(error.detail || `HTTP ${response.status}`);
  }
 
  return response.json();
}

A Calendar with Multiple Views

The calendar provides three views.

ViewPurposeFeatures
MonthlySee the overall scheduleResponsive event display (1–3 events)
WeeklyReview events by time24-hour timeline
DailyManage events in detailClick a time slot to create an event
{view === 'month' && <MonthView currentDate={currentDate} events={events} />}
{view === 'week' && <WeekView currentDate={currentDate} events={events} />}
{view === 'day' && <DayView currentDate={currentDate} events={events} />}

Recurring-Event UI

type Frequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
type Weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
 
interface RecurrencePattern {
  frequency: Frequency;
  interval?: number;
  weekdays?: Weekday[];
}

Selecting weekly recurrence shows weekday buttons.

{frequency === 'WEEKLY' && (
  <div className="flex flex-wrap gap-1">
    {WEEKDAY_OPTIONS.map((opt) => (
      <Button
        key={opt.value}
        variant={weekdays.includes(opt.value) ? 'default' : 'outline'}
        size="sm"
        className="w-9 h-9 p-0"
        onClick={() => toggleWeekday(opt.value)}
      >
        {opt.label}
      </Button>
    ))}
  </div>
)}

Responsive Design

Render the same data differently according to the screen size.

// 화면 크기별 표시 개수
const mobileCount = 1;
const tabletCount = 2;
const desktopCount = 3;
 
// 모바일: 1개만 표시
<div className="md:hidden">
  {dayEvents.slice(0, mobileCount).map(renderEvent)}
  {dayEvents.length > mobileCount && (
    <p className="text-xs">+{dayEvents.length - mobileCount}</p>
  )}
</div>
 
// 태블릿: 2개
<div className="hidden md:block lg:hidden">
  {dayEvents.slice(0, tabletCount).map(renderEvent)}
</div>
 
// 데스크톱: 3개
<div className="hidden lg:block">
  {dayEvents.slice(0, desktopCount).map(renderEvent)}
</div>

Part 3: Troubleshooting

Troubleshooting 1: Time Zones

Symptom: an event for January 3 appears on January 4

Cause: conversion to UTC by toISOString() shifts the date

// 문제 코드
const dateStr = date.toISOString().split('T')[0];
// 1월 3일 00:00 KST → "2026-01-02" (UTC 기준)

Fix: use local Date methods

// 해결 코드
const getEventsForDate = (date: Date) => {
  const year = date.getFullYear();
  const month = date.getMonth();
  const day = date.getDate();
 
  return events.filter((event) => {
    // 반복 일정은 occurrence_date 사용
    if (event.is_recurring && event.occurrence_date) {
      const [y, m, d] = event.occurrence_date.split('-').map(Number);
      return y === year && m - 1 === month && d === day;
    }
    // 일반 일정은 start_time 파싱
    const eventDate = new Date(event.start_time);
    return (
      eventDate.getFullYear() === year &&
      eventDate.getMonth() === month &&
      eventDate.getDate() === day
    );
  });
};

Troubleshooting 2: API Response Format Mismatch

Symptom: a t.filter is not a function error

Cause: the backend returns an object, { events: [...] }, while the frontend expects an array

Fix: add response-extraction logic

export async function getEvents(startDate: string, endDate: string) {
  const response = await fetchWithAuth(
    `/calendar/events?start_date=${startDate}&end_date=${endDate}`
  );
  // 객체에서 배열 추출
  return response?.events || [];
}

Resolving It with AI: I documented the calendar-related requests and responses in the backend project, then used AI in the frontend project to review that document and make the necessary corrections.

Troubleshooting 3: ESLint react-hooks Errors

Symptom: a react-hooks/set-state-in-effect error

Cause: multiple setState calls inside useEffect

Fix: combine the form state into a single object

// Before: 10개의 개별 useState
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
// ...
 
// After: 단일 FormState 객체
interface FormState {
  title: string;
  description: string;
  startDate: Date;
  startTime: string;
  // ...
}
 
const [formState, setFormState] = useState<FormState>(getInitialFormState);

Part 4: Test Strategy

The Backend Test Pyramid (Which Became a Trophy)

          / \
         /   \
        /     \
       /  E2E  \ 8개 (실제 Firebase, DB)
      /─────────\
     /Integration\ 43개 (Fake DI)
    /─────────────\
   /     Unit      \ 19개 (순수 로직)
   ─────────────────

Unit Tests for Recurrence Logic

def test_weekly_specific_days(self):
    """매주 월, 수, 금 반복"""
    occurrences = get_occurrences(
        rrule_str="FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR",
        dtstart=datetime(2024, 1, 1, 10, 0),
        range_start=date(2024, 1, 1),
        range_end=date(2024, 1, 7),
    )
 
    assert len(occurrences) == 3
    assert date(2024, 1, 1) in occurrences  # 월
    assert date(2024, 1, 3) in occurrences  # 수
    assert date(2024, 1, 5) in occurrences  # 금

Frontend E2E Tests (Playwright)

test('일정 생성 및 표시', async ({ page }) => {
  await page.goto('/');
 
  // 일정 추가 버튼 클릭
  await page.click('text=일정 추가');
 
  // 폼 작성
  await page.fill('[name=title]', '테스트 일정');
  await page.click('text=저장');
 
  // 캘린더에 일정 표시 확인
  await expect(page.locator('text=테스트 일정')).toBeVisible();
});

Part 5: Deployment

Nginx Configuration and SSL Certificate Issuance

The server's base environment—Ubuntu Server, Nginx, Certbot, firewall, and so on—was already configured in Setting Up the Blog Server. Here, only the calendar app's configuration needs to be added.

1. Create the Configuration File

sudo nano /etc/nginx/sites-available/calendar.conf

2. Paste the Contents and Save

Paste the following into the editor, then press Ctrl+O (save) → Enter → Ctrl+X (exit).

server {
    server_name calendar.funq.kr;
 
    location / {
        proxy_pass http://127.0.0.1:3002;
        proxy_http_version 1.1;
 
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
 
    listen 80;
}

3. Create a Symbolic Link

sudo ln -s /etc/nginx/sites-available/calendar.conf /etc/nginx/sites-enabled/

4. Test the Nginx Configuration Syntax

sudo nginx -t
# syntax is ok 메시지 확인

5. Restart Nginx

sudo systemctl reload nginx

6. Issue an SSL Certificate (Certbot)

sudo certbot --nginx -d calendar.funq.kr

Certbot automatically modifies the Nginx configuration and applies the HTTPS certificate.


The Result

Supported Features

FeatureDescription
Multiple viewsSwitch between monthly, weekly, and daily views
Event CRUDCreate, read, update, and delete
Recurring eventsDaily, weekly, monthly, yearly, and specific weekdays
CategoriesColor-based categorization
Family membersA different color for each member
Automatic registrationCreate a user automatically on first login
Responsive layoutSupports mobile, tablet, and desktop

Supported Recurrence Patterns

PatternExample API request
Every day{"frequency": "DAILY"}
Every week{"frequency": "WEEKLY"}
Every Monday, Wednesday, and Friday{"frequency": "WEEKLY", "weekdays": ["MO", "WE", "FR"]}
Every other week{"frequency": "WEEKLY", "interval": 2}
Every month{"frequency": "MONTHLY"}
Every year{"frequency": "YEARLY"}

Closing Thoughts

What I Learned

Backend

  1. The Protocol pattern: greatly improves testability
  2. The RRULE standard: do not reinvent the wheel; RFC 5545 is a proven standard
  3. Incremental improvement: improve as errors appear; with tests, there is less to fear

Frontend

  1. TypeScript strict mode: catches runtime errors at build time
  2. shadcn/ui: customizable components speed up development
  3. Time-zone handling: use local Date methods instead of toISOString()

Full stack

  1. API contracts: clearly define the response format between backend and frontend
  2. Automatic registration: reduce friction in user onboarding
  3. Error handling: use graceful degradation to keep working offline

About the Series

This is part 2 of the calendar project series:

  1. Building a Calendar with Claude Code and Superpowers: Brainstorm → Plan → Execute—the development workflow
  2. Full-Stack Calendar Development (this post)—implementation details

GitHub: https://github.com/nasodev/calendar

Live Demo: https://calendar.funq.kr

Leave a comment if you have any questions!

Related posts

Comments

Korean and English pages share this conversation.

Write a comment

0 / 5,000
You will need this password to edit or delete this comment.

Loading comments…