{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreifnh26hyn5zkpw6gq5erfa7f5g43hl4lacvv5adjaemfsvu3r747y",
"uri": "at://did:plc:5opbpi2nomj4y3d5kpwamkrd/app.bsky.feed.post/3mftfhufjgy32"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreigyp5ltrox6hjwwwnhr2s3pxv6yojwmkpw2w2wmk2bzxlxzboympu"
},
"mimeType": "image/png",
"size": 830327
},
"description": "A full-stack self-hosted app with email reminders, AI based gift suggestions, and zero framework overhead on the frontend.\n\n\nWhy Build a Birthday Calendar?\n\nI 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 ",
"path": "/building-an-ai-powered-birthday-calendar-with-fastapi-and-vanilla-javascript/",
"publishedAt": "2026-02-27T09:28:15.000Z",
"site": "https://corti.com",
"tags": [
"GitHub",
"@pytest.fixture",
"@app.on_event"
],
"textContent": "_A full-stack self-hosted app with email reminders, AI based gift suggestions, and zero framework overhead on the frontend._\n\n* * *\n\n## Why Build a Birthday Calendar?\n\nI 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.\n\nThe 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.\n\n## The Tech Stack\n\nLayer | Technology | Why\n---|---|---\nBackend | FastAPI + Uvicorn | Async, fast, automatic OpenAPI docs\nFrontend | Vanilla JS + CSS Grid | No build step, no node_modules\nStorage | JSON files | No database setup, human-readable, easy to back up\nAuth | JWT + bcrypt | Stateless tokens, industry-standard password hashing\nScheduling | APScheduler | In-process cron without system-level configuration\nEmail | SMTP via smtplib | Works with Gmail, Outlook, any SMTP provider\nAI | OpenAI GPT-4o | Personalized gift suggestions and birthday messages\n\nProduction dependencies total eight packages. The entire application is a single Python process.\n\n## Architecture Overview\n\n\n ┌──────────────────────────────────────────────┐\n │ Browser │\n │ Vanilla JS SPA ←→ localStorage (JWT) │\n └──────────────┬───────────────────────────────┘\n │ REST API (Bearer token)\n ┌──────────────▼───────────────────────────────┐\n │ FastAPI │\n │ ┌─────────┐ ┌──────────┐ ┌──────────────┐ │\n │ │ Auth │ │Birthdays │ │ Settings │ │\n │ │ Routes │ │ Routes │ │ Routes │ │\n │ └────┬────┘ └────┬─────┘ └──────┬───────┘ │\n │ │ │ │ │\n │ ┌────▼───────────▼──────────────▼────────┐ │\n │ │ Auth Layer (JWT + bcrypt) │ │\n │ └────────────────┬───────────────────────┘ │\n │ │ │\n │ ┌────────────────▼───────────────────────┐ │\n │ │ JSON Storage (thread-safe locks) │ │\n │ └────────────────┬───────────────────────┘ │\n │ │ │\n │ ┌────────────────▼───────────────────────┐ │\n │ │ APScheduler (daily cron trigger) │ │\n │ │ │ │ │\n │ │ ┌────▼─────┐ ┌───────────────┐ │ │\n │ │ │ SMTP │ │ OpenAI API │ │ │\n │ │ │ Email │ │ (optional) │ │ │\n │ │ └──────────┘ └───────────────┘ │ │\n │ └────────────────────────────────────────┘ │\n └──────────────────────────────────────────────┘\n │\n ┌──────────▼───────────┐\n │ data/ │\n │ ├─ birthdays.json │\n │ ├─ users.json │\n │ └─ settings.json │\n └──────────────────────┘\n\n\n## JSON Instead of a Database\n\nThe most unconventional choice in this project is using plain JSON files for persistence instead of SQLite or PostgreSQL. Here's the storage layer:\n\n\n class JSONStorage:\n def __init__(self, file_path: Path):\n self.file_path = file_path\n self.lock = Lock()\n self._ensure_file()\n\n def _read(self) -> dict:\n with self.lock:\n with open(self.file_path, \"r\") as f:\n return json.load(f)\n\n def _write(self, data: dict):\n with self.lock:\n with open(self.file_path, \"w\") as f:\n json.dump(data, f, indent=2)\n\n\nA `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.\n\nWhy this approach works:\n\n * **Zero setup** : No database server, no migrations, no connection strings\n * **Transparent** : You can open `birthdays.json` in any text editor and see your data\n * **Backup is a file copy** : `cp data/ backup/` — done\n * **Good enough** : A birthday calendar has dozens to hundreds of records, not millions\n\n\n\nThe tradeoff is obvious: this won't scale to concurrent users or large datasets. For a personal tool, that's perfectly fine.\n\n## Authentication: bcrypt + JWT\n\nAuthentication 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:\n\n\n from passlib.context import CryptContext\n\n pwd_context = CryptContext(schemes=[\"bcrypt\"], deprecated=\"auto\")\n\n def verify_password(plain_password, hashed_password):\n return pwd_context.verify(plain_password, hashed_password)\n\n def get_password_hash(password):\n return pwd_context.hash(password)\n\n\nAfter 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:\n\n\n def create_access_token(data: dict, expires_delta: timedelta = None):\n to_encode = data.copy()\n expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))\n to_encode.update({\"exp\": expire})\n return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)\n\n\nFastAPI's dependency injection makes protecting routes clean:\n\n\n async def get_current_user(token: str = Depends(oauth2_scheme)):\n payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n username = payload.get(\"sub\")\n user = user_storage.get_by_username(username)\n if user is None:\n raise credentials_exception\n return user\n\n\nAny 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`.\n\n## The Scheduler: Email Reminders with APScheduler\n\nInstead 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):\n\n\n def start_scheduler():\n scheduler = BackgroundScheduler()\n settings = settings_storage.get_email_settings()\n hour, minute = parse_reminder_time(settings.reminder_time)\n scheduler.add_job(\n check_and_send_reminders,\n CronTrigger(hour=hour, minute=minute),\n name=\"Birthday Reminder Check\",\n )\n scheduler.start()\n\n\nWhen the job fires, it:\n\n 1. Loads all birthdays from storage\n 2. Filters for birthdays occurring **tomorrow**\n 3. For each match, optionally calls the OpenAI API for personalized content\n 4. Composes an HTML email with the birthday list\n 5. Sends it via SMTP (with STARTTLS)\n\n\n\nThe scheduler is re-initialized whenever the admin saves settings — changing the reminder time takes effect immediately without restarting the service.\n\n## AI-Generated Gift Suggestions\n\nThe OpenAI integration is entirely optional. When enabled, the app sends a targeted prompt for each birthday:\n\n\n def generate_ai_suggestions(name, age, note, api_key):\n prompt = f\"\"\"Generate a short, warm birthday message and 5 gift ideas\n for {name}{f' who is turning {age}' if age else ''}.\n {f'About them: {note}' if note else ''}\n\n Format:\n MESSAGE: [your message]\n GIFTS:\n 1. [gift idea]\n ...\"\"\"\n\n client = OpenAI(api_key=api_key)\n response = client.chat.completions.create(\n model=\"gpt-4o\",\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=500,\n )\n\n\nThe 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.\n\nThe 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.\n\nAt roughly $0.02–0.04 per birthday with GPT-4o, running this for 50 contacts costs about $1–2 per year.\n\n## Vanilla JavaScript: No Framework Required\n\nThe 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:\n\n * `index.html` — The page structure with modal templates\n * `app.js` — All application logic (~500 lines)\n * `styles.css` — Styling with CSS Grid and animations\n\n\n\nThe calendar renders as a responsive CSS Grid:\n\n\n .calendar-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n gap: 20px;\n }\n\n\nThis automatically adjusts from a single column on mobile to four columns on a wide desktop — no media query needed for the basic layout.\n\nState 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:\n\n\n async function loadBirthdays() {\n const response = await fetch('/api/birthdays', {\n headers: { 'Authorization': `Bearer ${token}` }\n });\n birthdays = await response.json();\n renderCalendar();\n }\n\n\nThe \"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.\n\nIs 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.\n\n## Pydantic Models: Validation at the Boundary\n\nFastAPI's integration with Pydantic means request validation is declarative. The birthday model enforces valid months and days at the API boundary:\n\n\n class BirthdayCreate(BaseModel):\n name: str\n birth_year: Optional[int] = None\n month: int = Field(ge=1, le=12)\n day: int = Field(ge=1, le=31)\n note: Optional[str] = None\n contact_type: str = \"Friend\"\n\n\nSeparate 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:\n\n\n class BirthdayUpdate(BaseModel):\n name: Optional[str] = None\n month: Optional[int] = Field(None, ge=1, le=12)\n day: Optional[int] = Field(None, ge=1, le=31)\n # ...\n\n\nThe route handler merges the partial update with the existing record:\n\n\n updated = existing.copy(update=birthday.dict(exclude_unset=True))\n\n\nThis keeps the API flexible without sacrificing validation.\n\n## Deployment: systemd and a Shell Script\n\nThe app ships with a systemd unit file and an install script. The service definition handles auto-restart, environment variable loading, and proper process management:\n\n\n [Service]\n Type=simple\n User=YOUR_USER\n WorkingDirectory=/opt/birthdays\n EnvironmentFile=/opt/birthdays/.env\n ExecStart=/opt/birthdays/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8081\n Restart=always\n RestartSec=10\n\n\nThe 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.\n\nThe `.env` file holds all secrets:\n\n\n BIRTHDAYS_SECRET_KEY=your-secret-key-here\n BIRTHDAYS_ADMIN_USERNAME=admin\n BIRTHDAYS_ADMIN_PASSWORD=your-secure-password\n\n\n## Testing: 128 Tests with Full Isolation\n\nThe 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.\n\nThe 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:\n\n\n @pytest.fixture(autouse=True)\n def isolated_data_dir(tmp_path):\n test_birthday_storage = BirthdayStorage(tmp_path / \"birthdays.json\")\n\n with (\n patch(\"app.storage.birthday_storage\", test_birthday_storage),\n patch(\"app.routes.birthdays.birthday_storage\", test_birthday_storage),\n patch(\"app.scheduler.birthday_storage\", test_birthday_storage),\n # ... all import sites\n ):\n yield tmp_path\n\n\nThe `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.\n\nThe 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:\n\n\n def test_ai_suggestions_numbered_list(self, mock_openai):\n mock_openai.return_value = Mock(choices=[Mock(message=Mock(\n content=\"MESSAGE: Happy birthday!\\nGIFTS:\\n1. Book\\n2. Coffee mug\"\n ))])\n result = generate_ai_suggestions(\"Alice\", 30, \"Loves reading\", \"key\")\n assert \"Book\" in result[\"gifts\"]\n\n\n## What I'd Do Differently\n\nA few things I'd change in a v2:\n\n * **Structured AI output** : Instead of parsing free-text with regex, use OpenAI's JSON mode or function calling to get structured responses reliably.\n * **SQLite** : For anything beyond personal use, JSON files hit their limits quickly. SQLite would add minimal complexity while enabling proper queries.\n * **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.\n * **WebSocket updates** : The calendar doesn't refresh when another user adds a birthday. For multi-user setups, push updates would improve the experience.\n\n\n\n## Running It Yourself\n\n\n git clone https://github.com/TechPreacher/ai_birthday_calendar.git\n cd ai_birthday_calendar\n uv sync\n cp .env.example .env # Edit with your settings\n uv run uvicorn app.main:app --host 0.0.0.0 --port 8081\n\n\nOpen `http://localhost:8081`, log in with `admin` / `changeme`, and change the password immediately.\n\nThe full source is on GitHub. It's MIT licensed.\n\n* * *\n\n_Built with FastAPI, vanilla JavaScript, and a healthy appreciation for simplicity._",
"title": "Building an AI-Powered Birthday Calendar with FastAPI and Vanilla JavaScript",
"updatedAt": "2026-02-27T09:28:15.590Z"
}