diff --git a/backend/alembic/versions/007_add_recurrence_fields.py b/backend/alembic/versions/007_add_recurrence_fields.py new file mode 100644 index 0000000..7acaf79 --- /dev/null +++ b/backend/alembic/versions/007_add_recurrence_fields.py @@ -0,0 +1,62 @@ +"""Add recurrence fields to calendar_events + +Revision ID: 007 +Revises: 006 +Create Date: 2026-02-22 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '007' +down_revision: Union[str, None] = '006' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # parent_event_id: self-referential FK, nullable, ON DELETE CASCADE + op.add_column( + 'calendar_events', + sa.Column('parent_event_id', sa.Integer(), nullable=True) + ) + op.create_foreign_key( + 'fk_calendar_events_parent_event_id', + 'calendar_events', + 'calendar_events', + ['parent_event_id'], + ['id'], + ondelete='CASCADE', + ) + + # is_recurring: tracks whether this row is part of a recurring series + op.add_column( + 'calendar_events', + sa.Column( + 'is_recurring', + sa.Boolean(), + nullable=False, + server_default='false', + ) + ) + + # original_start: the originally computed occurrence datetime (naive, no TZ) + op.add_column( + 'calendar_events', + sa.Column('original_start', sa.DateTime(), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column('calendar_events', 'original_start') + op.drop_column('calendar_events', 'is_recurring') + op.drop_constraint( + 'fk_calendar_events_parent_event_id', + 'calendar_events', + type_='foreignkey', + ) + op.drop_column('calendar_events', 'parent_event_id') diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py index 3fec35e..725d3eb 100644 --- a/backend/app/models/calendar_event.py +++ b/backend/app/models/calendar_event.py @@ -19,6 +19,19 @@ class CalendarEvent(Base): recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False) + + # Recurrence fields + # parent_event_id: set on child events; NULL on the parent template row + parent_event_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("calendar_events.id", ondelete="CASCADE"), + nullable=True, + ) + # is_recurring: True on both the parent template and all generated children + is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # original_start: the originally computed occurrence datetime (children only) + original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 4b1dc2c..acdc73e 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,17 +1,24 @@ +import json from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, delete from sqlalchemy.orm import selectinload -from typing import Optional, List, Any +from typing import Optional, List, Any, Literal + from datetime import date, datetime, timedelta from app.database import get_db from app.models.calendar_event import CalendarEvent from app.models.calendar import Calendar from app.models.person import Person -from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse +from app.schemas.calendar_event import ( + CalendarEventCreate, + CalendarEventUpdate, + CalendarEventResponse, +) from app.routers.auth import get_current_session from app.models.settings import Settings +from app.services.recurrence import generate_occurrences router = APIRouter() @@ -33,6 +40,9 @@ def _event_to_dict(event: CalendarEvent) -> dict: "calendar_name": event.calendar.name if event.calendar else "", "calendar_color": event.calendar.color if event.calendar else "", "is_virtual": False, + "parent_event_id": event.parent_event_id, + "is_recurring": event.is_recurring, + "original_start": event.original_start, "created_at": event.created_at, "updated_at": event.updated_at, } @@ -56,7 +66,6 @@ def _birthday_events_for_range( continue bday: date = person.birthday - # Generate for each year the birthday falls in the range for year in range(range_start.year, range_end.year + 1): try: bday_this_year = bday.replace(year=year) @@ -86,6 +95,9 @@ def _birthday_events_for_range( "calendar_name": "Birthdays", "calendar_color": bday_calendar_color, "is_virtual": True, + "parent_event_id": None, + "is_recurring": False, + "original_start": None, "created_at": start_dt, "updated_at": start_dt, }) @@ -107,14 +119,29 @@ async def get_events( start: Optional[date] = Query(None), end: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_session), ) -> List[Any]: - """Get all calendar events with optional date range filtering, including virtual birthday events.""" + """ + Get all calendar events with optional date range filtering. + + Parent template rows (is_recurring=True, parent_event_id IS NULL, + recurrence_rule IS NOT NULL) are excluded — their materialised children + are what get displayed on the calendar. + """ query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) ) + # Exclude parent template rows — they are not directly rendered + query = query.where( + ~( + (CalendarEvent.is_recurring == True) + & (CalendarEvent.parent_event_id == None) + & (CalendarEvent.recurrence_rule != None) + ) + ) + if start: query = query.where(CalendarEvent.end_datetime >= start) if end: @@ -127,7 +154,7 @@ async def get_events( response: List[dict] = [_event_to_dict(e) for e in events] - # Fetch Birthdays calendar; only generate virtual events if it exists and is visible + # Fetch Birthdays calendar; only generate virtual events if visible bday_result = await db.execute( select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) ) @@ -153,7 +180,7 @@ async def get_events( async def create_event( event: CalendarEventCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_session), ): if event.end_datetime < event.start_datetime: raise HTTPException(status_code=400, detail="End datetime must be after start datetime") @@ -164,24 +191,58 @@ async def create_event( if not data.get("calendar_id"): data["calendar_id"] = await _get_default_calendar_id(db) - new_event = CalendarEvent(**data) - db.add(new_event) - await db.commit() + # Serialize RecurrenceRule object to JSON string for DB storage + rule_obj = data.pop("recurrence_rule", None) + rule_json: Optional[str] = json.dumps(rule_obj) if rule_obj else None - # Re-fetch with relationship eagerly loaded - result = await db.execute( - select(CalendarEvent) - .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == new_event.id) - ) - return result.scalar_one() + if rule_json: + # Parent template: is_recurring=True, no parent_event_id + parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True) + db.add(parent) + await db.flush() # assign parent.id before generating children + + children = generate_occurrences(parent) + for child in children: + db.add(child) + + await db.commit() + + # Return the first child so the caller sees a concrete occurrence + # (The parent template is intentionally hidden from list views) + if children: + result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == children[0].id) + ) + return result.scalar_one() + + # Fallback: no children generated (e.g. bad rule), return parent + result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == parent.id) + ) + return result.scalar_one() + + else: + new_event = CalendarEvent(**data, recurrence_rule=None) + db.add(new_event) + await db.commit() + + result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == new_event.id) + ) + return result.scalar_one() @router.get("/{event_id}", response_model=CalendarEventResponse) async def get_event( event_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_session), ): result = await db.execute( select(CalendarEvent) @@ -201,7 +262,7 @@ async def update_event( event_id: int, event_update: CalendarEventUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_session), ): result = await db.execute( select(CalendarEvent) @@ -215,31 +276,85 @@ async def update_event( update_data = event_update.model_dump(exclude_unset=True) - start = update_data.get("start_datetime", event.start_datetime) - end = update_data.get("end_datetime", event.end_datetime) + # Extract scope before applying fields to the model + scope: Optional[str] = update_data.pop("edit_scope", None) - if end < start: + # Serialize RecurrenceRule → JSON string if present in update payload + rule_obj = update_data.pop("recurrence_rule", None) + if rule_obj is not None: + update_data["recurrence_rule"] = json.dumps(rule_obj) if rule_obj else None + + start = update_data.get("start_datetime", event.start_datetime) + end_dt = update_data.get("end_datetime", event.end_datetime) + if end_dt < start: raise HTTPException(status_code=400, detail="End datetime must be after start datetime") - for key, value in update_data.items(): - setattr(event, key, value) + if scope == "this": + # Update only this occurrence and detach it from the series + for key, value in update_data.items(): + setattr(event, key, value) + # Detach from parent so it's an independent event going forward + event.parent_event_id = None + event.is_recurring = False + await db.commit() - await db.commit() + elif scope == "this_and_future": + # Delete all future siblings (same parent, original_start >= this event's original_start) + parent_id = event.parent_event_id + this_original_start = event.original_start or event.start_datetime + + if parent_id is not None: + await db.execute( + delete(CalendarEvent).where( + CalendarEvent.parent_event_id == parent_id, + CalendarEvent.original_start >= this_original_start, + ) + ) + + # Update this event with the new data, making it a new parent + for key, value in update_data.items(): + setattr(event, key, value) + event.parent_event_id = None + event.is_recurring = True + event.original_start = None + + # If a new recurrence_rule was provided, regenerate children from this point + if event.recurrence_rule: + await db.flush() + children = generate_occurrences(event) + for child in children: + db.add(child) + else: + # Not part of a series — plain update + for key, value in update_data.items(): + setattr(event, key, value) + + await db.commit() + + else: + # No scope — plain update (non-recurring events or full-series metadata) + for key, value in update_data.items(): + setattr(event, key, value) + await db.commit() - # Re-fetch to ensure relationship is fresh after commit result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where(CalendarEvent.id == event_id) ) - return result.scalar_one() + updated = result.scalar_one_or_none() + if not updated: + # Event was deleted as part of this_and_future; 404 is appropriate + raise HTTPException(status_code=404, detail="Event no longer exists after update") + return updated @router.delete("/{event_id}", status_code=204) async def delete_event( event_id: int, + scope: Optional[Literal["this", "this_and_future"]] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_session), ): result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) event = result.scalar_one_or_none() @@ -247,6 +362,29 @@ async def delete_event( if not event: raise HTTPException(status_code=404, detail="Calendar event not found") - await db.delete(event) + if scope == "this": + # Delete just this one occurrence + await db.delete(event) + + elif scope == "this_and_future": + parent_id = event.parent_event_id + this_original_start = event.original_start or event.start_datetime + + if parent_id is not None: + # Delete this + all future siblings + await db.execute( + delete(CalendarEvent).where( + CalendarEvent.parent_event_id == parent_id, + CalendarEvent.original_start >= this_original_start, + ) + ) + else: + # This event IS the parent — delete it and all children (CASCADE handles children) + await db.delete(event) + + else: + # Plain delete (non-recurring; or delete entire series if parent) + await db.delete(event) + await db.commit() return None diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index 469dbc5..038cbc0 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -1,6 +1,19 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime -from typing import Optional +from typing import Literal, Optional + + +class RecurrenceRule(BaseModel): + """Structured recurrence rule — serialized to/from JSON string in the DB column.""" + type: Literal["every_n_days", "weekly", "monthly_nth_weekday", "monthly_date"] + # every_n_days + interval: Optional[int] = None + # weekly / monthly_nth_weekday + weekday: Optional[int] = None # 0=Mon … 6=Sun + # monthly_nth_weekday + week: Optional[int] = None # 1-4 + # monthly_date + day: Optional[int] = None # 1-31 class CalendarEventCreate(BaseModel): @@ -11,7 +24,7 @@ class CalendarEventCreate(BaseModel): all_day: bool = False color: Optional[str] = None location_id: Optional[int] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string is_starred: bool = False calendar_id: Optional[int] = None # If None, server assigns default calendar @@ -24,9 +37,11 @@ class CalendarEventUpdate(BaseModel): all_day: Optional[bool] = None color: Optional[str] = None location_id: Optional[int] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string is_starred: Optional[bool] = None calendar_id: Optional[int] = None + # Controls which occurrences an edit applies to; absent = non-recurring or whole-series + edit_scope: Optional[Literal["this", "this_and_future"]] = None class CalendarEventResponse(BaseModel): @@ -38,12 +53,16 @@ class CalendarEventResponse(BaseModel): all_day: bool color: Optional[str] location_id: Optional[int] - recurrence_rule: Optional[str] + recurrence_rule: Optional[str] # raw JSON string from DB; frontend parses is_starred: bool calendar_id: int calendar_name: str calendar_color: str is_virtual: bool = False + # Recurrence fields + parent_event_id: Optional[int] = None + is_recurring: bool = False + original_start: Optional[datetime] = None created_at: datetime updated_at: datetime diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py new file mode 100644 index 0000000..4fcddb1 --- /dev/null +++ b/backend/app/services/recurrence.py @@ -0,0 +1,176 @@ +""" +Recurrence service — generates materialized child CalendarEvent ORM objects +from a parent event's recurrence_rule JSON string. + +All datetimes are naive (no timezone) to match TIMESTAMP WITHOUT TIME ZONE in DB. +""" +import json +from datetime import datetime, timedelta +from typing import Optional + +from app.models.calendar_event import CalendarEvent + + +def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]: + """ + Return the date of the Nth (1-4) occurrence of a given weekday (0=Mon…6=Sun) + in a month, or None if it doesn't exist (e.g. 5th Monday in a short month). + """ + # Find the first occurrence of the weekday in the month + first_day = datetime(year, month, 1) + # days_ahead: how many days until the target weekday + days_ahead = (weekday - first_day.weekday()) % 7 + first_occurrence = first_day + timedelta(days=days_ahead) + # Advance by (week - 1) full weeks + target = first_occurrence + timedelta(weeks=week - 1) + if target.month != month: + return None + return target + + +def generate_occurrences( + parent: CalendarEvent, + horizon_days: int = 365, +) -> list[CalendarEvent]: + """ + Parse parent.recurrence_rule (JSON string) and materialise child CalendarEvent + objects up to horizon_days from parent.start_datetime. + + Supported rule types: + - every_n_days : {"type": "every_n_days", "interval": N} + - weekly : {"type": "weekly", "weekday": 0-6} (0=Mon, 6=Sun) + - monthly_nth_weekday: {"type": "monthly_nth_weekday", "week": 1-4, "weekday": 0-6} + - monthly_date : {"type": "monthly_date", "day": 1-31} + + Returns unsaved ORM objects — the caller must add them to the session. + """ + if not parent.recurrence_rule: + return [] + + try: + rule: dict = json.loads(parent.recurrence_rule) + except (json.JSONDecodeError, TypeError): + return [] + + rule_type: str = rule.get("type", "") + parent_start: datetime = parent.start_datetime + parent_end: datetime = parent.end_datetime + duration: timedelta = parent_end - parent_start + horizon: datetime = parent_start + timedelta(days=horizon_days) + + # Fields to copy verbatim from parent to each child + shared_fields = dict( + title=parent.title, + description=parent.description, + all_day=parent.all_day, + color=parent.color, + location_id=parent.location_id, + is_starred=parent.is_starred, + calendar_id=parent.calendar_id, + # Children do not carry the recurrence_rule; parent is the template + recurrence_rule=None, + parent_event_id=parent.id, + is_recurring=True, + ) + + occurrences: list[CalendarEvent] = [] + + def _make_child(occ_start: datetime) -> CalendarEvent: + occ_end = occ_start + duration + return CalendarEvent( + **shared_fields, + start_datetime=occ_start, + end_datetime=occ_end, + original_start=occ_start, + ) + + if rule_type == "every_n_days": + interval: int = int(rule.get("interval", 1)) + if interval < 1: + interval = 1 + current = parent_start + timedelta(days=interval) + while current < horizon: + occurrences.append(_make_child(current)) + current += timedelta(days=interval) + + elif rule_type == "weekly": + weekday: int = int(rule.get("weekday", parent_start.weekday())) + # Start from the week after the parent + days_ahead = (weekday - parent_start.weekday()) % 7 + if days_ahead == 0: + days_ahead = 7 # skip the parent's own week + current = parent_start + timedelta(days=days_ahead) + while current < horizon: + occurrences.append(_make_child(current)) + current += timedelta(weeks=1) + + elif rule_type == "monthly_nth_weekday": + week: int = int(rule.get("week", 1)) + weekday = int(rule.get("weekday", parent_start.weekday())) + # Advance month by month + year, month = parent_start.year, parent_start.month + # Move to the next month from the parent + month += 1 + if month > 12: + month = 1 + year += 1 + while True: + target = _nth_weekday_of_month(year, month, weekday, week) + if target is None: + # Skip months where Nth weekday doesn't exist + pass + else: + # Preserve the time-of-day from the parent + occ_start = target.replace( + hour=parent_start.hour, + minute=parent_start.minute, + second=parent_start.second, + microsecond=0, + ) + if occ_start >= horizon: + break + occurrences.append(_make_child(occ_start)) + month += 1 + if month > 12: + month = 1 + year += 1 + # Safety: if we've passed the horizon year by 2, stop + if year > horizon.year + 1: + break + + elif rule_type == "monthly_date": + day: int = int(rule.get("day", parent_start.day)) + year, month = parent_start.year, parent_start.month + month += 1 + if month > 12: + month = 1 + year += 1 + while True: + # Some months don't have day 29-31 + try: + occ_start = datetime( + year, month, day, + parent_start.hour, + parent_start.minute, + parent_start.second, + ) + except ValueError: + # Day out of range for this month — skip + month += 1 + if month > 12: + month = 1 + year += 1 + if year > horizon.year + 1: + break + continue + if occ_start >= horizon: + break + occurrences.append(_make_child(occ_start)) + month += 1 + if month > 12: + month = 1 + year += 1 + if year > horizon.year + 1: + break + + return occurrences