From ca095389719d757ba377b56b462cc0a92189d691 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 12 Jul 2026 21:38:37 +0200 Subject: [PATCH] Add birthday calendar backend support Birthday calendars are ordinary local calendars flagged is_birthday, so they flow to all clients via the merge read and inherit sharing/colors/reminders. - models: LocalCalendar.is_birthday + birthday_notify_days_before; LocalEvent.external_uid (Contacts dedup) + birth_year - build_local_event_dict: server-computed display_title "Name (age)" per occurrence, is_birthday flag for the client cake icon, and a reminder injected from birthday_notify_days_before so mobile schedulers fire it - groups combined view keeps the birthday display_title instead of overwriting it - local_router: calendar flags + event fields on create/update, plus GET /calendars/{id}/birthdays for importer reconcile by external_uid - additive SQLite migrations Co-Authored-By: Claude Opus 4.8 --- backend/local_events_util.py | 19 ++++++++++ backend/main.py | 23 ++++++++++++ backend/models.py | 11 ++++++ backend/routers/groups_router.py | 11 ++++-- backend/routers/local_router.py | 63 ++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/backend/local_events_util.py b/backend/local_events_util.py index 3e40884..c45e6d4 100644 --- a/backend/local_events_util.py +++ b/backend/local_events_util.py @@ -132,6 +132,25 @@ def build_local_event_dict( "private": bool(ev.is_private), "reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], } + # Birthday calendars: the server owns the presentation. It flags the event so + # clients can show a cake icon, appends the age computed for THIS occurrence + # (so it stays correct as years pass), and — when the calendar defines a + # "notify N days before" — injects a reminder so the mobile schedulers fire + # it. Web has no notification delivery, so the reminder is display-only there. + if getattr(cal, "is_birthday", False): + d["is_birthday"] = True + display = ev.title + if ev.birth_year: + try: + occ_year = int(str(d["start"])[:4]) + age = occ_year - int(ev.birth_year) + if age >= 0: + display = f"{ev.title} ({age})" + except (ValueError, TypeError): + pass + d["display_title"] = display + if not d["reminders"] and cal.birthday_notify_days_before is not None: + d["reminders"] = [int(cal.birthday_notify_days_before) * 1440] if owner is not None: d["owner"] = owner if is_group_event: diff --git a/backend/main.py b/backend/main.py index 5c4a343..c5e18fe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -259,6 +259,29 @@ def _migrate(): except Exception: pass + # Birthday calendars: flag + per-calendar "notify N days before". + for col, ddl in ( + ("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"), + ("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"), + ): + try: + conn.execute(text(ddl)) + conn.commit() + logging.info("Migration: added %s to local_calendars", col) + except Exception: + pass + # Birthday events: stable external id (Contacts dedup) + birth year. + for col, ddl in ( + ("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"), + ("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"), + ): + try: + conn.execute(text(ddl)) + conn.commit() + logging.info("Migration: added %s to local_events", col) + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 5ebb39f..41cd8bf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -153,6 +153,12 @@ class LocalCalendar(Base): caldav_published = Column(Boolean, default=False, nullable=False) dav_token = Column(String(64), nullable=True, unique=True) dav_ctag = Column(String(32), nullable=True) + # Birthday calendar: events are all-day, yearly-recurring; the server adds the + # age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon. + is_birthday = Column(Boolean, default=False, nullable=False) + # How many days before a birthday to remind (0 = on the day). NULL = no reminder. + # Injected as an event reminder on read so the mobile schedulers fire it. + birthday_notify_days_before = Column(Integer, nullable=True) user = relationship("User", back_populates="local_calendars") events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") @@ -183,6 +189,11 @@ class LocalEvent(Base): is_private = Column(Boolean, default=False) # CalDAV entity tag — changes on every write so CalDAV clients detect updates. etag = Column(String(32), nullable=True) + # Stable external identity for imported entries (e.g. a Contacts birthday keyed + # by "contact:"), so a re-sync can mirror the address book without dupes. + external_uid = Column(String(255), nullable=True, index=True) + # Birth year for birthday events; NULL = year unknown (no age shown). + birth_year = Column(Integer, nullable=True) calendar = relationship("LocalCalendar", back_populates="events") creator = relationship("User") diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index ea27799..e2bf905 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -420,10 +420,13 @@ def combined_events( b["display_color"] = group_cal_color if is_group else member_color.get(owner_id) # Decorated title (group icon / owner name) computed server-side # so all clients render identically; raw `title` kept for editing. - b["display_title"] = _decorate_title( - b.get("title", ""), is_group=is_group, creator=b.get("creator"), - owner=owner, me_id=current_user.id, - ) + # A birthday event already carries an age display_title from + # build_local_event_dict — keep it rather than clobber the age. + if not b.get("is_birthday"): + b["display_title"] = _decorate_title( + b.get("title", ""), is_group=is_group, creator=b.get("creator"), + owner=owner, me_id=current_user.id, + ) all_events.append(b) # Each member shares exactly one calendar into their groups, chosen in their diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 9896cb8..cde8e65 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -25,6 +25,8 @@ def _now_iso() -> str: class CalendarCreate(BaseModel): name: str color: str = "#34a853" + is_birthday: bool = False + birthday_notify_days_before: Optional[int] = None class CalendarUpdate(BaseModel): @@ -33,6 +35,8 @@ class CalendarUpdate(BaseModel): enabled: Optional[bool] = None reminders_enabled: Optional[bool] = None caldav_published: Optional[bool] = None + is_birthday: Optional[bool] = None + birthday_notify_days_before: Optional[int] = None class EventCreate(BaseModel): @@ -47,6 +51,8 @@ class EventCreate(BaseModel): rrule: Optional[str] = None private: bool = False reminders: Optional[List[int]] = None # minutes before start (0 = at start) + external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup) + birth_year: Optional[int] = None # birthday events; NULL = year unknown class EventUpdate(BaseModel): @@ -61,6 +67,8 @@ class EventUpdate(BaseModel): exdate: Optional[str] = None private: Optional[bool] = None reminders: Optional[List[int]] = None + external_uid: Optional[str] = None + birth_year: Optional[int] = None class ShareCreate(BaseModel): @@ -80,6 +88,8 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, "enabled": cal.enabled, "reminders_enabled": bool(cal.reminders_enabled), "caldav_published": bool(cal.caldav_published), + "is_birthday": bool(cal.is_birthday), + "birthday_notify_days_before": cal.birthday_notify_days_before, "type": "local", "owned": owned, } @@ -205,6 +215,8 @@ def create_calendar( user_id=current_user.id, name=data.name, color=data.color, + is_birthday=data.is_birthday, + birthday_notify_days_before=data.birthday_notify_days_before, ) db.add(cal) db.commit() @@ -238,6 +250,14 @@ def update_calendar( cal.enabled = data.enabled if data.reminders_enabled is not None: cal.reminders_enabled = data.reminders_enabled + if data.is_birthday is not None: + cal.is_birthday = data.is_birthday + if data.birthday_notify_days_before is not None: + # -1 is the client's sentinel for "clear the reminder" (JSON has no way to + # send SQL NULL through an Optional that also means "unchanged"). + cal.birthday_notify_days_before = ( + None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before + ) if data.caldav_published is not None: cal.caldav_published = data.caldav_published if data.caldav_published: @@ -368,6 +388,8 @@ def create_event( rrule=data.rrule, is_private=data.private, reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None), + external_uid=data.external_uid, + birth_year=data.birth_year, creator_id=current_user.id, # server-side, never from the client ) db.add(ev) @@ -420,6 +442,11 @@ def update_event( ev.exdate = ",".join(dates) if data.reminders is not None: ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None + if data.external_uid is not None: + ev.external_uid = data.external_uid + if data.birth_year is not None: + # -1 is the client's sentinel for "clear" (year became unknown). + ev.birth_year = None if data.birth_year < 0 else data.birth_year dav_util.bump_dav(ev.calendar, ev) db.commit() return {"ok": True} @@ -438,6 +465,42 @@ def delete_event( return {"ok": True} +@router.get("/calendars/{calendar_id}/birthdays") +def list_birthday_entries( + calendar_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Raw (unexpanded) rows of a birthday calendar so a client importer can + reconcile against the address book by ``external_uid``. Contact-sourced rows + carry an ``external_uid``; manually added birthdays have ``None`` and must be + left untouched by the importer.""" + cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) + events = ( + db.query(models.LocalEvent) + .filter(models.LocalEvent.calendar_id == cal.id) + .all() + ) + out = [] + for ev in events: + month = day = None + try: + parts = (ev.start or "")[:10].split("-") + if len(parts) == 3: + month, day = int(parts[1]), int(parts[2]) + except (ValueError, IndexError): + pass + out.append({ + "uid": ev.uid, + "external_uid": ev.external_uid, + "title": ev.title, + "month": month, + "day": day, + "birth_year": ev.birth_year, + }) + return out + + # ── Sharing (owner only) ────────────────────────────────── @router.get("/calendars/{calendar_id}/shares")