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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-12 21:38:37 +02:00
parent d426f8985c
commit ca09538971
5 changed files with 123 additions and 4 deletions

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:<id>"), 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")

View File

@@ -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

View File

@@ -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")