> ## Content Index
> Fetch the complete content index at: https://corti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Building an AI-Powered Birthday Calendar with FastAPI and Vanilla JavaScript
- URL: https://corti.com/building-an-ai-powered-birthday-calendar-with-fastapi-and-vanilla-javascript/
- Published: 2026-02-27T09:28:15.000Z
- Updated: 2026-02-27T09:28:15.000Z
- Author: Sascha Corti

*A full-stack self-hosted app with email reminders, AI based gift suggestions, and zero framework overhead on the frontend.*

---

## Why Build a Birthday Calendar?

I kept forgetting birthdays. Not the big ones, those are hard to miss, but the colleague whose birthday is next Tuesday, or the friend who always remembers mine but whose date I can never recall. I wanted something simple, self-hosted, and private. No cloud service holding my contacts. No subscription. Just a clean calendar that sends me an email the day before with a reminder and, as a bonus, some AI-generated gift ideas.

The result is **AI Birthday Calendar**: a full-stack web application built with FastAPI, vanilla JavaScript, and JSON file storage. No database to configure, no frontend framework to bundle, and it runs on a Raspberry Pi.

![](https://corti.com/content/images/2026/02/tracker2.png)

## The Tech Stack

| Layer      | Technology            | Why                                                  |
| ---------- | --------------------- | ---------------------------------------------------- |
| Backend    | FastAPI + Uvicorn     | Async, fast, automatic OpenAPI docs                  |
| Frontend   | Vanilla JS + CSS Grid | No build step, no node\_modules                      |
| Storage    | JSON files            | No database setup, human-readable, easy to back up   |
| Auth       | JWT + bcrypt          | Stateless tokens, industry-standard password hashing |
| Scheduling | APScheduler           | In-process cron without system-level configuration   |
| Email      | SMTP via smtplib      | Works with Gmail, Outlook, any SMTP provider         |
| AI         | OpenAI GPT-4o         | Personalized gift suggestions and birthday messages  |

Production dependencies total eight packages. The entire application is a single Python process.

## Architecture Overview

```
┌──────────────────────────────────────────────┐
│                  Browser                     │
│  Vanilla JS SPA  ←→  localStorage (JWT)      │
└──────────────┬───────────────────────────────┘
               │ REST API (Bearer token)
┌──────────────▼───────────────────────────────┐
│              FastAPI                         │
│  ┌─────────┐ ┌──────────┐ ┌──────────────┐   │
│  │  Auth   │ │Birthdays │ │  Settings    │   │
│  │ Routes  │ │  Routes  │ │   Routes     │   │
│  └────┬────┘ └────┬─────┘ └──────┬───────┘   │
│       │           │              │           │
│  ┌────▼───────────▼──────────────▼────────┐  │
│  │         Auth Layer (JWT + bcrypt)      │  │
│  └────────────────┬───────────────────────┘  │
│                   │                          │
│  ┌────────────────▼───────────────────────┐  │
│  │     JSON Storage (thread-safe locks)   │  │
│  └────────────────┬───────────────────────┘  │
│                   │                          │
│  ┌────────────────▼───────────────────────┐  │
│  │     APScheduler (daily cron trigger)   │  │
│  │         │                              │  │
│  │    ┌────▼─────┐    ┌───────────────┐   │  │
│  │    │  SMTP    │    │  OpenAI API   │   │  │
│  │    │  Email   │    │  (optional)   │   │  │
│  │    └──────────┘    └───────────────┘   │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘
               │
    ┌──────────▼───────────┐
    │   data/              │
    │   ├─ birthdays.json  │
    │   ├─ users.json      │
    │   └─ settings.json   │
    └──────────────────────┘

```

## JSON Instead of a Database

The most unconventional choice in this project is using plain JSON files for persistence instead of SQLite or PostgreSQL. Here's the storage layer:

```python
class JSONStorage:
    def __init__(self, file_path: Path):
        self.file_path = file_path
        self.lock = Lock()
        self._ensure_file()

    def _read(self) -> dict:
        with self.lock:
            with open(self.file_path, "r") as f:
                return json.load(f)

    def _write(self, data: dict):
        with self.lock:
            with open(self.file_path, "w") as f:
                json.dump(data, f, indent=2)

```

A `threading.Lock` prevents concurrent writes from corrupting the file. Three specialized subclasses — `UserStorage`, `BirthdayStorage`, and `SettingsStorage` — each handle their own CRUD operations and are instantiated as module-level singletons.

Why this approach works:

- **Zero setup**: No database server, no migrations, no connection strings
- **Transparent**: You can open `birthdays.json` in any text editor and see your data
- **Backup is a file copy**: `cp data/ backup/` — done
- **Good enough**: A birthday calendar has dozens to hundreds of records, not millions

The tradeoff is obvious: this won't scale to concurrent users or large datasets. For a personal tool, that's perfectly fine.

## Authentication: bcrypt + JWT

Authentication uses two well-established building blocks. Passwords are hashed with bcrypt, which is memory-hard and resistant to GPU-based brute force attacks. Each hash includes a random salt, so identical passwords produce different hashes:

```python
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password):
    return pwd_context.hash(password)

```

After successful login, the server issues a JWT (JSON Web Token) signed with HS256\. The token contains the username and an expiration timestamp. The frontend stores it in `localStorage` and sends it as a Bearer token with every API request:

```python
def create_access_token(data: dict, expires_delta: timedelta = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

```

FastAPI's dependency injection makes protecting routes clean:

```python
async def get_current_user(token: str = Depends(oauth2_scheme)):
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    username = payload.get("sub")
    user = user_storage.get_by_username(username)
    if user is None:
        raise credentials_exception
    return user

```

Any route that needs authentication simply adds `current_user: User = Depends(get_current_active_user)` to its signature. Admin-only endpoints add an additional check on `current_user.is_admin`.

## The Scheduler: Email Reminders with APScheduler

Instead of relying on system cron, the application runs APScheduler's `BackgroundScheduler` inside the same process. A `CronTrigger` fires a check once daily at a configurable time (default 09:00):

```python
def start_scheduler():
    scheduler = BackgroundScheduler()
    settings = settings_storage.get_email_settings()
    hour, minute = parse_reminder_time(settings.reminder_time)
    scheduler.add_job(
        check_and_send_reminders,
        CronTrigger(hour=hour, minute=minute),
        name="Birthday Reminder Check",
    )
    scheduler.start()

```

When the job fires, it:

1. Loads all birthdays from storage
2. Filters for birthdays occurring **tomorrow**
3. For each match, optionally calls the OpenAI API for personalized content
4. Composes an HTML email with the birthday list
5. Sends it via SMTP (with STARTTLS)

The scheduler is re-initialized whenever the admin saves settings — changing the reminder time takes effect immediately without restarting the service.

## AI-Generated Gift Suggestions

The OpenAI integration is entirely optional. When enabled, the app sends a targeted prompt for each birthday:

```python
def generate_ai_suggestions(name, age, note, api_key):
    prompt = f"""Generate a short, warm birthday message and 5 gift ideas
    for {name}{f' who is turning {age}' if age else ''}.
    {f'About them: {note}' if note else ''}

    Format:
    MESSAGE: [your message]
    GIFTS:
    1. [gift idea]
    ..."""

    client = OpenAI(api_key=api_key)
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=500,
    )

```

The notes field on each birthday entry is what makes this useful. "Loves dark chocolate and mystery novels" or "Mechanical keyboard enthusiast" gives the model enough context to suggest relevant gifts rather than generic ones.

The integration degrades gracefully. If the API key is missing, expired, or the quota is exceeded, the email still goes out — just without the AI section. The error is logged, and the admin can test the integration from the settings panel before relying on it.

At roughly $0.02–0.04 per birthday with GPT-4o, running this for 50 contacts costs about $1–2 per year.

## Vanilla JavaScript: No Framework Required

The frontend is a single-page application built with plain JavaScript, HTML, and CSS. No React, no Vue, no build step. The entire client is three files:

- `index.html` — The page structure with modal templates
- `app.js` — All application logic (\~500 lines)
- `styles.css` — Styling with CSS Grid and animations

The calendar renders as a responsive CSS Grid:

```css
.calendar-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
    gap: 20px;
}

```

This automatically adjusts from a single column on mobile to four columns on a wide desktop — no media query needed for the basic layout.

State management is straightforward: a few module-level variables hold the current token, user info, and birthday list. The JWT is persisted in `localStorage` so sessions survive page reloads. Every API call includes the token as a Bearer header:

```javascript
async function loadBirthdays() {
    const response = await fetch('/api/birthdays', {
        headers: { 'Authorization': `Bearer ${token}` }
    });
    birthdays = await response.json();
    renderCalendar();
}

```

The "next birthday" feature highlights the upcoming birthday with a pulse animation and a badge showing the number of days remaining. The calculation handles year boundaries correctly — if today is December 28th, it correctly identifies a January 3rd birthday as being 6 days away.

Is this approach right for every project? No. But for a personal tool with a handful of views and straightforward interactions, vanilla JS keeps the stack simple and eliminates an entire category of build tooling, version conflicts, and framework churn.

## Pydantic Models: Validation at the Boundary

FastAPI's integration with Pydantic means request validation is declarative. The birthday model enforces valid months and days at the API boundary:

```python
class BirthdayCreate(BaseModel):
    name: str
    birth_year: Optional[int] = None
    month: int = Field(ge=1, le=12)
    day: int = Field(ge=1, le=31)
    note: Optional[str] = None
    contact_type: str = "Friend"

```

Separate models handle creation, update, and response. The update model makes every field optional, enabling partial updates — change just the note without re-sending the entire record:

```python
class BirthdayUpdate(BaseModel):
    name: Optional[str] = None
    month: Optional[int] = Field(None, ge=1, le=12)
    day: Optional[int] = Field(None, ge=1, le=31)
    # ...

```

The route handler merges the partial update with the existing record:

```python
updated = existing.copy(update=birthday.dict(exclude_unset=True))

```

This keeps the API flexible without sacrificing validation.

## Deployment: systemd and a Shell Script

The app ships with a systemd unit file and an install script. The service definition handles auto-restart, environment variable loading, and proper process management:

```ini
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=/opt/birthdays
EnvironmentFile=/opt/birthdays/.env
ExecStart=/opt/birthdays/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8081
Restart=always
RestartSec=10

```

The install script copies the service file, enables it, and starts it — then prints the access URL and a reminder to change the default password. For production use behind a reverse proxy (nginx, Caddy), the app binds to `0.0.0.0:8081` and the proxy handles TLS termination.

The `.env` file holds all secrets:

```env
BIRTHDAYS_SECRET_KEY=your-secret-key-here
BIRTHDAYS_ADMIN_USERNAME=admin
BIRTHDAYS_ADMIN_PASSWORD=your-secure-password

```

## Testing: 128 Tests with Full Isolation

The test suite covers all layers: models, storage, authentication, scheduling, and HTTP endpoints. The key challenge was test isolation — the storage layer uses module-level singletons, so tests need to swap them out cleanly.

The solution patches every import site. Since Python module imports create separate references, patching `app.storage.birthday_storage` alone isn't enough. The fixtures patch it everywhere it's used:

```python
@pytest.fixture(autouse=True)
def isolated_data_dir(tmp_path):
    test_birthday_storage = BirthdayStorage(tmp_path / "birthdays.json")

    with (
        patch("app.storage.birthday_storage", test_birthday_storage),
        patch("app.routes.birthdays.birthday_storage", test_birthday_storage),
        patch("app.scheduler.birthday_storage", test_birthday_storage),
        # ... all import sites
    ):
        yield tmp_path

```

The `autouse=True` ensures every test runs against fresh temporary files. Pre-generated JWT tokens in fixtures (`admin_headers`, `regular_headers`) reduce boilerplate in endpoint tests.

The scheduler tests demonstrate proper mocking of external services. OpenAI responses are mocked with various formats (numbered lists, dashed lists, malformed responses) to verify the parser handles real-world variation:

```python
def test_ai_suggestions_numbered_list(self, mock_openai):
    mock_openai.return_value = Mock(choices=[Mock(message=Mock(
        content="MESSAGE: Happy birthday!\nGIFTS:\n1. Book\n2. Coffee mug"
    ))])
    result = generate_ai_suggestions("Alice", 30, "Loves reading", "key")
    assert "Book" in result["gifts"]

```

## What I'd Do Differently

A few things I'd change in a v2:

- **Structured AI output**: Instead of parsing free-text with regex, use OpenAI's JSON mode or function calling to get structured responses reliably.
- **SQLite**: For anything beyond personal use, JSON files hit their limits quickly. SQLite would add minimal complexity while enabling proper queries.
- **Pydantic v2 migration**: The codebase still uses `.dict()` instead of `.model_dump()` and `@app.on_event` instead of lifespan handlers. These work but generate deprecation warnings.
- **WebSocket updates**: The calendar doesn't refresh when another user adds a birthday. For multi-user setups, push updates would improve the experience.

## Running It Yourself

```bash
git clone https://github.com/TechPreacher/ai_birthday_calendar.git
cd ai_birthday_calendar
uv sync
cp .env.example .env    # Edit with your settings
uv run uvicorn app.main:app --host 0.0.0.0 --port 8081

```

Open `http://localhost:8081`, log in with `admin` / `changeme`, and change the password immediately.

The full source is on [GitHub](https://github.com/TechPreacher/ai%5Fbirthday%5Fcalendar?ref=corti.com). It's MIT licensed.

---

*Built with FastAPI, vanilla JavaScript, and a healthy appreciation for simplicity.*