Sfoglia il codice sorgente

Add pluggable event backend abstraction (CalDAV + SQLite)

Introduces EventRecord (typed dataclass replacing untyped event dicts),
EventBackend protocol, CalDAVBackend wrapper, SQLiteBackend, StoreBacked
base class, and get_backend() factory. All stores and actions migrated
from dict access to EventRecord attribute access. Config gains a
backend: caldav | sqlite field.
ZiB 4 mesi fa
parent
commit
609d73ee6f

+ 15 - 13
room_cal/_utils.py

@@ -66,18 +66,18 @@ def iter_dates(start: date, end: date):
     d += timedelta(days=1)
 
 
-def event_dates(event: dict, start_date: date, end_date: date):
-  """Yield dates covered by a CalDAV event dict, clamped to [start_date, end_date]."""
-  ev_start = event['start'].date()
-  ev_end = event['end'].date()
-  last_day = (ev_end - timedelta(days=1)) if event['all_day'] else ev_end
+def event_dates(event, start_date: date, end_date: date):
+  """Yield dates covered by an EventRecord, clamped to [start_date, end_date]."""
+  ev_start = event.start.date()
+  ev_end = event.end.date()
+  last_day = (ev_end - timedelta(days=1)) if event.all_day else ev_end
   for d in iter_dates(ev_start, last_day):
     if start_date <= d <= end_date:
       yield d
 
 
 async def drop_stale_events(
-  events: list[dict],
+  events: list,
   covered: set[date],
   *,
   domain: str,
@@ -87,11 +87,11 @@ async def drop_stale_events(
   """Delete events whose source domain/id match but whose date is no longer covered."""
   to_delete = []
   for ev in events:
-    xd = safe_json_loads(ev['x_data'])
+    xd = safe_json_loads(ev.x_data)
     stored = xd.get('source', {}) if xd else {}
     if not isinstance(stored, dict):
       continue
-    ev_date = ev['start'].date()
+    ev_date = ev.start.date()
     if ev_date not in covered and stored.get('domain') == domain and stored.get('id') == id_:
       to_delete.append(ev)
   if to_delete:
@@ -99,17 +99,17 @@ async def drop_stale_events(
 
 
 def filter_events(
-  events: list[dict],
+  events: list,
   prefix: str,
   *,
   all_day: bool | None = None,
   require_x_data: bool = True,
-) -> list[dict]:
+) -> list:
   return [
     e for e in events
-    if e['title'].startswith(prefix)
-    and (all_day is None or e['all_day'] == all_day)
-    and (not require_x_data or e['x_data'])
+    if e.title.startswith(prefix)
+    and (all_day is None or e.all_day == all_day)
+    and (not require_x_data or e.x_data)
   ]
 
 
@@ -338,6 +338,7 @@ class Config:
   rules: RulesConfig = field(default_factory=RulesConfig)
   calendars: list[CalendarEntry] = field(default_factory=list)
   api_url: str | None = None
+  backend: str = 'caldav'
 
   @classmethod
   def from_dict(cls, d: dict) -> 'Config':
@@ -347,6 +348,7 @@ class Config:
       rules=RulesConfig.from_dict(d.get('rules', {})),
       calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
       api_url=d.get('api_url'),
+      backend=d.get('backend', 'caldav'),
     )
 
 

+ 6 - 6
room_cal/actions/apply_discounts.py

@@ -50,18 +50,18 @@ async def apply_discounts(
 
   booked: set[date] = set()
   for e in res_events:
-    last_night = e['end'].date() - timedelta(days=1)
-    d = e['start'].date()
+    last_night = e.end.date() - timedelta(days=1)
+    d = e.start.date()
     while d <= last_night:
       booked.add(d)
       d += timedelta(days=1)
 
   to_update: list[tuple] = []
   for e in price_events:
-    target = e['start'].date()
+    target = e.start.date()
     if target in booked:
       continue
-    prices_before = safe_json_loads(e['x_data'])
+    prices_before = safe_json_loads(e.x_data)
     if prices_before is None:
       continue
     base = prices_before.get('base') or prices_before.get('net')
@@ -75,13 +75,13 @@ async def apply_discounts(
   results: list[dict] = []
   if to_update:
     new_prices_list = await asyncio.gather(*[
-      cal.set(e['start'].date(), base, existing=e, discount_rec=dr)
+      cal.set(e.start.date(), base, existing=e, discount_rec=dr)
       for e, base, dr, _ in to_update
     ])
     for (e, base, dr, prices_before), new_prices in zip(to_update, new_prices_list):
       service_keys = list(new_prices.service_prices)
       results.append({
-        'date': e['start'].date(),
+        'date': e.start.date(),
         'old_prices': {k: prices_before.get(k) for k in service_keys},
         'new_prices': dict(new_prices.service_prices),
       })

+ 6 - 6
room_cal/actions/apply_rules.py

@@ -47,7 +47,7 @@ async def apply_rules(
       generated = await _rules.run_pipeline(name, start_date, end_date, initial_data=await closed_cal.get_range(start_date, end_date, events=existing_closed))
       to_close = {d: rec for d, rec in generated.items() if rec.services}
       await drop_stale_events(existing_closed, set(to_close), domain='rules', id_=name, cal=closed_cal)
-      closed_by_date = {e['start'].date(): e for e in existing_closed}
+      closed_by_date = {e.start.date(): e for e in existing_closed}
 
       async def _write_closed(d):
         rec = dataclasses.replace(to_close[d], source=to_close[d].source or pipeline_source)
@@ -70,14 +70,14 @@ async def apply_rules(
 
       booked: set[date] = set()
       for e in res_events:
-        last_night = e['end'].date() - timedelta(days=1)
-        for d in iter_dates(e['start'].date(), last_night):
+        last_night = e.end.date() - timedelta(days=1)
+        for d in iter_dates(e.start.date(), last_night):
           booked.add(d)
 
       price_by_date: dict[date, tuple] = {}
       for e in price_events:
-        d = e['start'].date()
-        xd = safe_json_loads(e['x_data'])
+        d = e.start.date()
+        xd = safe_json_loads(e.x_data)
         if xd:
           base = xd.get('base') or xd.get('net')
           price_by_date[d] = (base, e)
@@ -99,7 +99,7 @@ async def apply_rules(
       existing_discounts = await discount_cal.list(start_date, end_date)
       generated = await _rules.run_pipeline(name, start_date, end_date, initial_data=await discount_cal.get_range(start_date, end_date, events=existing_discounts))
       await drop_stale_events(existing_discounts, set(generated), domain='rules', id_=name, cal=discount_cal)
-      discount_by_date = {e['start'].date(): e for e in existing_discounts}
+      discount_by_date = {e.start.date(): e for e in existing_discounts}
 
       async def _write_discount(d):
         rec = dataclasses.replace(generated[d], source=generated[d].source or pipeline_source)

+ 40 - 34
room_cal/actions/diff.py

@@ -2,7 +2,7 @@ import asyncio
 from datetime import date, timedelta
 from typing import TYPE_CHECKING
 
-from room_cal.cal import get_calendar
+from room_cal.cal import get_backend
 from room_cal.closed import Closed
 from room_cal.discount import Discount
 from room_cal.pricing.price_calendar import CalPrice
@@ -74,9 +74,12 @@ async def _diff_prices(
   """
   from room_cal.reservations import Reservation
 
-  cal = get_calendar()
+  from datetime import datetime
+  backend = get_backend()
+  start_dt = datetime(start_date.year, start_date.month, start_date.day)
+  end_dt = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
   events, api_prices, res_events = await asyncio.gather(
-    cal.fetch_date_range(start_date, end_date),
+    backend.fetch_range(start_dt, end_dt),
     api.get_prices_range(start_date, end_date),
     Reservation().list(start_date, end_date),
   )
@@ -86,19 +89,18 @@ async def _diff_prices(
   svc_keys = [s.key for s in _services()]
   cal_prices: dict[str, PriceData] = {}
   for e in filter_events(events, _PRICE_PREFIX, all_day=True):
-    d = safe_json_loads(e['x_data'])
+    d = safe_json_loads(e.x_data)
     if d is not None:
-      cal_prices[e['start'].date().isoformat()] = PriceData.from_dict(d, svc_keys)
-
+      cal_prices[e.start.date().isoformat()] = PriceData.from_dict(d, svc_keys)
 
   booked_svc_by_date: dict[date, str] = {}
   for e in res_events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     if xd:
       svc = xd.get('service')
       if svc:
-        last_night = e['end'].date() - timedelta(days=1)
-        for d in iter_dates(e['start'].date(), last_night):
+        last_night = e.end.date() - timedelta(days=1)
+        for d in iter_dates(e.start.date(), last_night):
           booked_svc_by_date[d] = svc
 
   today = local_today()
@@ -147,9 +149,9 @@ async def _diff_closed(
   cal_events = await closed_cal.list(start_date, end_date)
   cal_by_date: dict[str, list[str]] = {}
   for e in cal_events:
-    d = safe_json_loads(e['x_data'])
+    d = safe_json_loads(e.x_data)
     if d is not None:
-      cal_by_date[e['start'].date().isoformat()] = sorted(d.get('closed_services', []))
+      cal_by_date[e.start.date().isoformat()] = sorted(d.get('closed_services', []))
 
   api_by_date = await api.get_closed_by_date(start_date, end_date)
   api_by_date = {ds: sorted(svcs) for ds, svcs in api_by_date.items()}
@@ -185,9 +187,9 @@ async def _diff_reservations(
   cal_events = await res_cal.list(start_date, end_date)
   cal_by_id: dict[str, dict] = {}
   for e in cal_events:
-    if e['start'].date() < start_date:
+    if e.start.date() < start_date:
       continue  # checkin before range — belongs to a prior period's scope
-    d = safe_json_loads(e['x_data'])
+    d = safe_json_loads(e.x_data)
     if d:
       rid = d.get('reservation_id')
       if rid:
@@ -195,8 +197,8 @@ async def _diff_reservations(
           'reservation_id': rid,
           'guest_name':     d.get('guest_name', ''),
           'service':        d.get('service', ''),
-          'checkin_date':   e['start'].date(),
-          'checkout_date':  e['end'].date(),
+          'checkin_date':   e.start.date(),
+          'checkout_date':  e.end.date(),
         }
 
   service_reservations = await api.get_reservations(start_date, end_date)
@@ -251,9 +253,9 @@ async def _diff_user_cal_closed(
 
   room_dates: set[date] = set()
   for e in room_events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     if xd and xd.get('source', {}).get('domain') == 'user_cal':
-      room_dates.add(e['start'].date())
+      room_dates.add(e.start.date())
 
   user_cal_dates: set[date] = set()
   for ev in user_cal_events:
@@ -292,11 +294,11 @@ async def _diff_ical_feeds_closed(
     feed_name = entry.name
     room_dates: set[date] = set()
     for e in room_events:
-      xd = safe_json_loads(e['x_data'])
+      xd = safe_json_loads(e.x_data)
       if xd:
         src = xd.get('source', {})
         if src.get('domain') == 'ical_feeds' and src.get('id') == feed_name:
-          room_dates.add(e['start'].date())
+          room_dates.add(e.start.date())
 
     feed_events = await fetch_calendar(entry)
     feed_dates: set[date] = set()
@@ -333,11 +335,11 @@ async def _diff_rule_discounts(
   for name in room_cal.rules.list_pipelines(type_='discount'):
     room_by_date: dict[date, dict] = {}
     for e in room_events:
-      xd = safe_json_loads(e['x_data'])
+      xd = safe_json_loads(e.x_data)
       if xd:
         src = xd.get('source', {})
         if src.get('domain') == 'rules' and src.get('id') == name:
-          room_by_date[e['start'].date()] = {'type': xd['type'], 'value': xd['value']}
+          room_by_date[e.start.date()] = {'type': xd['type'], 'value': xd['value']}
 
     pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
     rule_by_date = {d: {'type': rec.type, 'value': rec.value} for d, rec in pipeline_out.items()}
@@ -371,11 +373,11 @@ async def _diff_rule_closed(
   for name in room_cal.rules.list_pipelines(type_='closed'):
     room_dates: set[date] = set()
     for e in room_events:
-      xd = safe_json_loads(e['x_data'])
+      xd = safe_json_loads(e.x_data)
       if xd:
         src = xd.get('source', {})
         if src.get('domain') == 'rules' and src.get('id') == name:
-          room_dates.add(e['start'].date())
+          room_dates.add(e.start.date())
 
     pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
     rule_dates: set[date] = {d for d, rec in pipeline_out.items() if rec.services}
@@ -410,17 +412,17 @@ async def _diff_rule_prices(
 
   booked: set[date] = set()
   for e in res_events:
-    last_night = e['end'].date() - timedelta(days=1)
-    for d in iter_dates(e['start'].date(), last_night):
+    last_night = e.end.date() - timedelta(days=1)
+    for d in iter_dates(e.start.date(), last_night):
       booked.add(d)
 
   local_base_by_date: dict[date, int] = {}
   for e in price_events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     if xd:
       base = xd.get('base') or xd.get('net')
       if base is not None:
-        local_base_by_date[e['start'].date()] = base
+        local_base_by_date[e.start.date()] = base
 
   results: dict[str, list[dict]] = {}
   for name in room_cal.rules.list_pipelines(type_='price'):
@@ -448,7 +450,11 @@ async def _diff_orphans(
 
   Each row: {'date', 'event_type': 'closed'|'discount', 'title', 'source': dict|None}
   """
-  events = await get_calendar().fetch_date_range(start_date, end_date)
+  from datetime import datetime
+  backend = get_backend()
+  start_dt = datetime(start_date.year, start_date.month, start_date.day)
+  end_dt = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
+  events = await backend.fetch_range(start_dt, end_dt)
 
   closed_events   = filter_events(events, 'Closed: ',   all_day=True, require_x_data=False)
   discount_events = filter_events(events, 'Discount: ', all_day=True, require_x_data=False)
@@ -456,24 +462,24 @@ async def _diff_orphans(
   orphans: list[dict] = []
 
   for e in closed_events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     domain = xd.get('source', {}).get('domain') if xd else None
     if domain not in registered_domains():
       orphans.append({
-        'date':       e['start'].date(),
+        'date':       e.start.date(),
         'event_type': 'closed',
-        'title':      e['title'],
+        'title':      e.title,
         'source':     (xd.get('source') if xd else None),
       })
 
   for e in discount_events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     domain = xd.get('source', {}).get('domain') if xd else None
     if domain not in registered_domains():
       orphans.append({
-        'date':       e['start'].date(),
+        'date':       e.start.date(),
         'event_type': 'discount',
-        'title':      e['title'],
+        'title':      e.title,
         'source':     (xd.get('source') if xd else None),
       })
 

+ 7 - 7
room_cal/actions/fetch_closed.py

@@ -17,7 +17,7 @@ async def fetch_closed(
   *,
   api: 'ServiceAPI | None' = None,
 ) -> FetchResult[list[dict]]:
-  """Fetch per-service blocked dates and write them to CalDAV as 'Closed' all-day events.
+  """Fetch per-service blocked dates and write them to the calendar as 'Closed' all-day events.
 
   Deletes any existing 'Closed' events for dates that are now open.
   Returns FetchResult with list of {date, closed_services} for dates that have
@@ -31,9 +31,9 @@ async def fetch_closed(
   res_cal = Reservation()
 
   existing_events = await closed_cal.list(start_date, end_date)
-  existing_by_date: dict[str, dict] = {}
+  existing_by_date: dict[str, object] = {}
   for e in existing_events:
-    ds = e['start'].date().isoformat()
+    ds = e.start.date().isoformat()
     existing_by_date[ds] = e
 
   closed_by_date = await api.get_closed_by_date(start_date, end_date)
@@ -42,7 +42,7 @@ async def fetch_closed(
 
   for ds, event in existing_by_date.items():
     if ds not in closed_by_date:
-      xd = safe_json_loads(event['x_data'])
+      xd = safe_json_loads(event.x_data)
       event_domain = (xd or {}).get('source', {}).get('domain')
       if event_domain == 'service_api' or is_ephemeral_domain(event_domain):
         changed = True
@@ -53,8 +53,8 @@ async def fetch_closed(
     day = date.fromisoformat(ds)
     reservations_on_day = await res_cal.list(day, day)
     if reservations_on_day:
-      res_start = min(e['start'].date() for e in reservations_on_day)
-      res_end   = max(e['end'].date()   for e in reservations_on_day)
+      res_start = min(e.start.date() for e in reservations_on_day)
+      res_end   = max(e.end.date()   for e in reservations_on_day)
       await fetch_reservations(min(start_date, res_start), max(end_date, res_end), api=api)
     else:
       services_str = ', '.join(sorted(closed_services))
@@ -63,7 +63,7 @@ async def fetch_closed(
       if existing is None:
         changed = True
       else:
-        xd = safe_json_loads(existing['x_data'])
+        xd = safe_json_loads(existing.x_data)
         existing_services = sorted((xd or {}).get('closed_services') or [])
         if existing_services != sorted(closed_services):
           changed = True

+ 4 - 4
room_cal/actions/fetch_feed.py

@@ -30,7 +30,7 @@ async def fetch_feed(
 
   events = await fetch_calendar(entry)
   if title is not None:
-    events = [e for e in events if e.get('title') == title]
+    events = [e for e in events if e.title == title]
 
   blocked: set[date] = set()
   for event in events:
@@ -44,13 +44,13 @@ async def fetch_feed(
 
   existing_covered: set[date] = set()
   for e in existing_closed:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     src = (xd or {}).get('source', {})
     if src.get('domain') == 'ical_feeds' and src.get('id') == feed_name:
-      existing_covered.add(e['start'].date())
+      existing_covered.add(e.start.date())
 
   await drop_stale_events(existing_closed, blocked, domain='ical_feeds', id_=feed_name, cal=closed_cal)
-  closed_by_date = {e['start'].date(): e for e in existing_closed}
+  closed_by_date = {e.start.date(): e for e in existing_closed}
 
   source = {'domain': 'ical_feeds', 'name': 'Calendar =>', 'id': feed_name}
   synced: list[date] = []

+ 8 - 9
room_cal/actions/fetch_prices.py

@@ -22,7 +22,7 @@ async def fetch_prices(
   service: str | None = None,
   booked_only: bool = False,
 ) -> FetchResult[list[dict]]:
-  """Fetch per-service list prices from the API and write net prices to CalDAV.
+  """Fetch per-service list prices from the API and write net prices to the calendar.
 
   Converts each service's list price back to a net price via the calc module, then
   calls CalPrice.set so all derived list/retail prices are recomputed consistently.
@@ -49,16 +49,16 @@ async def fetch_prices(
     Reservation().list(start_date, end_date),
     Discount().get_range(start_date, end_date),
   )
-  existing_by_date = {e['start'].date(): e for e in existing_events}
+  existing_by_date = {e.start.date(): e for e in existing_events}
 
   booked_svc_by_date: dict[str, str] = {}
   for e in res_events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     if xd:
       svc = xd.get('service')
       if svc:
-        last_night = e['end'].date() - timedelta(days=1)
-        for d in iter_dates(e['start'].date(), last_night):
+        last_night = e.end.date() - timedelta(days=1)
+        for d in iter_dates(e.start.date(), last_night):
           booked_svc_by_date[d.isoformat()] = svc
 
   changed = False
@@ -79,9 +79,8 @@ async def fetch_prices(
       continue
     d = date.fromisoformat(ds)
     discount_rec = discount_by_date.get(d)
-    # The platform list price was derived from the discounted net, so reverse the
-    # local discount to recover the true base before calling CalPrice.set, which
-    # will re-apply the discount. Without this, each fetch→push cycle compounds
+    # Reverse the local discount to recover the true base before calling CalPrice.set,
+    # which will re-apply the discount. Without this, each fetch→push cycle compounds
     # the discount and ratchets the base price down.
     base = net
     if discount_rec:
@@ -93,7 +92,7 @@ async def fetch_prices(
     if existing is None:
       changed = True
     else:
-      xd = safe_json_loads(existing['x_data'])
+      xd = safe_json_loads(existing.x_data)
       if (xd or {}).get('net') != net:
         changed = True
     await cal.set(d, base, existing=existing, discount_rec=discount_rec)

+ 13 - 13
room_cal/actions/fetch_reservations.py

@@ -29,7 +29,7 @@ async def _remove_reservation_closures(
 ) -> None:
   events = await closed.list(checkin_date, checkout_date - timedelta(days=1))
   for event in events:
-    d = safe_json_loads(event['x_data'])
+    d = safe_json_loads(event.x_data)
     stored = d.get('source', {}) if d else {}
     if isinstance(stored, dict) and _source_matches(
       stored, source['domain'], source['key'], source['id']
@@ -43,10 +43,10 @@ async def fetch_reservations(
   *,
   api: 'ServiceAPI | None' = None,
 ) -> FetchResult[list[dict]]:
-  """Fetch reservations from all services and write them to the CalDAV calendar.
+  """Fetch reservations from all services and write them to the calendar.
 
   Calls ServiceAPI.get_reservations() for the given date range, then upserts
-  each result into CalDAV via Reservation.set(). Returns FetchResult with the
+  each result into the calendar via Reservation.set(). Returns FetchResult with the
   list of synced reservation data dicts and a changed flag.
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
@@ -58,11 +58,11 @@ async def fetch_reservations(
   all_service_keys = [s.key for s in _services()]
 
   existing_events = await res_cal.list(start_date, end_date)
-  existing_by_id: dict[str, tuple[dict, dict]] = {}
+  existing_by_id: dict[str, tuple] = {}
   for e in existing_events:
-    if e['start'].date() < start_date:
+    if e.start.date() < start_date:
       continue  # checkin before range — belongs to a prior period's scope
-    d = safe_json_loads(e['x_data'])
+    d = safe_json_loads(e.x_data)
     if d:
       rid = d.get('reservation_id')
       if rid:
@@ -77,8 +77,8 @@ async def fetch_reservations(
     if rid not in service_ids:
       changed = True
       await res_cal.delete(event)
-      checkin_date = event['start'].date()
-      checkout_date = event['end'].date()
+      checkin_date = event.start.date()
+      checkout_date = event.end.date()
       source = {'domain': 'reservations', 'name': service_name(data['service']), 'key': data['service'], 'id': rid}
       await _remove_reservation_closures(closed, checkin_date, checkout_date, source)
 
@@ -107,8 +107,8 @@ async def fetch_reservations(
     existing = existing_by_id.get(r['id'])
     if existing:
       old_event, _ = existing
-      old_checkin = old_event['start'].date()
-      old_checkout = old_event['end'].date()
+      old_checkin = old_event.start.date()
+      old_checkout = old_event.end.date()
       if old_checkin != r['start_date'] or old_checkout != r['end_date']:
         changed = True
         closure_removal_tasks.append(
@@ -129,16 +129,16 @@ async def fetch_reservations(
   else:
     eff_closed_start, eff_closed_end = start_date, end_date
   existing_closed = await closed.list(eff_closed_start, eff_closed_end)
-  closed_by_date = {e['start'].date(): e for e in existing_closed}
+  closed_by_date = {e.start.date(): e for e in existing_closed}
   await asyncio.gather(*[
     closed.set(day, data, existing=closed_by_date.get(day))
     for day, data in closed_set_inputs
   ])
 
   ucal_events = await user_cal.find_services(start_date, end_date)
-  ucal_by_id: dict[str, dict] = {}
+  ucal_by_id: dict[str, object] = {}
   for e in ucal_events:
-    d = safe_json_loads(e['x_data'])
+    d = safe_json_loads(e.x_data)
     if d:
       rid = d.get('reservation_id')
       if rid:

+ 3 - 3
room_cal/actions/fetch_user_cal.py

@@ -34,13 +34,13 @@ async def fetch_user_cal(
 
   existing_covered: set[date] = set()
   for e in existing_closed:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     src = (xd or {}).get('source', {})
     if src.get('domain') == _USER_CAL_SOURCE and src.get('id') == USER_CAL_DEFAULT:
-      existing_covered.add(e['start'].date())
+      existing_covered.add(e.start.date())
 
   await drop_stale_events(existing_closed, blocked, domain=_USER_CAL_SOURCE, id_=USER_CAL_DEFAULT, cal=closed_cal)
-  closed_by_date = {e['start'].date(): e for e in existing_closed}
+  closed_by_date = {e.start.date(): e for e in existing_closed}
 
   synced_closed: list[date] = []
   for d in sorted(blocked):

+ 1 - 1
room_cal/actions/fill_prices.py

@@ -20,7 +20,7 @@ async def fill_prices(
   """
   cal = CalPrice()
   existing_events = await cal.list(start_date, end_date)
-  existing_dates = {e['start'].date() for e in existing_events}
+  existing_dates = {e.start.date() for e in existing_events}
 
   cfg = get_config().global_.prices
   wd = weekday if weekday is not None else cfg.weekday

+ 9 - 9
room_cal/actions/local_refresh.py

@@ -16,8 +16,8 @@ async def refresh_prices(start: date, end: date) -> None:
   discount_by_date = await discount.get_range(start, end)
   found = 0
   for e in price_events:
-    d = e['start'].date()
-    prices = safe_json_loads(e['x_data'])
+    d = e.start.date()
+    prices = safe_json_loads(e.x_data)
     base = prices.get('base') if prices else None
     if base is None:
       continue
@@ -32,8 +32,8 @@ async def refresh_closed(start: date, end: date) -> None:
   events = await closed.list(start, end)
   found = 0
   for e in events:
-    d = e['start'].date()
-    xd = safe_json_loads(e['x_data'])
+    d = e.start.date()
+    xd = safe_json_loads(e.x_data)
     if xd is None:
       continue
     source = xd.get('source') or {}
@@ -53,8 +53,8 @@ async def refresh_discounts(start: date, end: date) -> None:
   events = await discount.list(start, end)
   found = 0
   for e in events:
-    d = e['start'].date()
-    xd = safe_json_loads(e['x_data'])
+    d = e.start.date()
+    xd = safe_json_loads(e.x_data)
     if xd is None:
       continue
     source = xd.get('source') or {}
@@ -75,11 +75,11 @@ async def refresh_reservations(start: date, end: date) -> None:
   events = await reservations.list(start, end)
   found = 0
   for e in events:
-    xd = safe_json_loads(e['x_data'])
+    xd = safe_json_loads(e.x_data)
     if xd is None:
       continue
-    checkin_date = e['start'].date()
-    checkout_date = e['end'].date()
+    checkin_date = e.start.date()
+    checkout_date = e.end.date()
     data = {
       'checkin_date': checkin_date,
       'checkout_date': checkout_date,

+ 4 - 4
room_cal/actions/push_busy.py

@@ -28,17 +28,17 @@ async def push_busy(start_date: date, end_date: date) -> FetchResult[dict]:
   busy_dates: set[date] = set()
 
   for e in closed_events:
-    busy_dates.add(e['start'].date())
+    busy_dates.add(e.start.date())
 
   for e in res_events:
-    checkin = e['start'].date()
-    last_night = e['end'].date() - timedelta(days=1)
+    checkin = e.start.date()
+    last_night = e.end.date() - timedelta(days=1)
     for d in iter_dates(checkin, last_night):
       if start_date <= d <= end_date:
         busy_dates.add(d)
 
   existing = await busy_cal.list(start_date, end_date)
-  existing_by_date: dict[date, dict] = {e['start'].date(): e for e in existing}
+  existing_by_date = {e.start.date(): e for e in existing}
   existing_dates = set(existing_by_date)
 
   to_add = sorted(busy_dates - existing_dates)

+ 2 - 2
room_cal/actions/push_closed.py

@@ -31,8 +31,8 @@ async def push_closed(
   cal_events = await closed_cal.list(start_date, end_date)
   cal_by_date: set[str] = set()
   for e in cal_events:
-    ds = e['start'].date().isoformat()
-    d = safe_json_loads(e['x_data'])
+    ds = e.start.date().isoformat()
+    d = safe_json_loads(e.x_data)
     if d is None or d.get('closed_services'):
       cal_by_date.add(ds)
 

+ 10 - 5
room_cal/actions/push_prices.py

@@ -1,7 +1,9 @@
 from datetime import date
 from typing import TYPE_CHECKING
 
-from room_cal.cal import Calendar
+from datetime import datetime, timedelta
+
+from room_cal.cal import get_backend
 from room_cal._utils import safe_json_loads, filter_events, services as _services
 from room_cal.pricing.price_calendar import PriceData
 
@@ -31,15 +33,18 @@ async def push_prices(
   if api is None:
     api = _ServiceAPI()
 
-  cal = Calendar()
-  events = await cal.fetch_date_range(start_date, end_date)
+  backend = get_backend()
+  events = await backend.fetch_range(
+    datetime(start_date.year, start_date.month, start_date.day),
+    datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1),
+  )
 
   svc_keys = [s.key for s in _services()]
   cal_prices: dict[str, PriceData] = {}
   for e in filter_events(events, _PRICE_PREFIX, all_day=True):
-    d = safe_json_loads(e['x_data'])
+    d = safe_json_loads(e.x_data)
     if d is not None:
-      cal_prices[e['start'].date().isoformat()] = PriceData.from_dict(d, svc_keys)
+      cal_prices[e.start.date().isoformat()] = PriceData.from_dict(d, svc_keys)
 
   results: list[dict] = []
   for ds in sorted(cal_prices):

+ 2 - 2
room_cal/api/_routes/closed.py

@@ -20,9 +20,9 @@ async def get_closed_range(start_date: date, end_date: date):
   events = await Closed(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
   by_date: dict[str, list] = {str(d): [] for d in iter_dates(start_date, end_date)}
   for ev in events:
-    d = ev['start'].date()
+    d = ev.start.date()
     if start_date <= d <= end_date:
-      xd = safe_json_loads(ev['x_data'])
+      xd = safe_json_loads(ev.x_data)
       by_date[str(d)] = (xd or {}).get('closed_services', [])
   return by_date
 

+ 2 - 2
room_cal/api/_routes/discounts.py

@@ -22,9 +22,9 @@ async def get_discount_range(start_date: date, end_date: date):
   events = await Discount(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
   by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
   for ev in events:
-    d = ev['start'].date()
+    d = ev.start.date()
     if start_date <= d <= end_date:
-      xd = safe_json_loads(ev['x_data'])
+      xd = safe_json_loads(ev.x_data)
       if xd:
         by_date[str(d)] = {'type': xd.get('type'), 'value': xd.get('value')}
   return by_date

+ 7 - 7
room_cal/api/_routes/reservations.py

@@ -7,10 +7,10 @@ from room_cal._utils import safe_json_loads, iter_dates
 router = APIRouter()
 
 
-def _enrich(ev: dict) -> dict:
-  xd = safe_json_loads(ev['x_data']) or {}
-  xd['checkin_date'] = ev['start'].date().isoformat()
-  xd['checkout_date'] = ev['end'].date().isoformat()
+def _enrich(ev) -> dict:
+  xd = safe_json_loads(ev.x_data) or {}
+  xd['checkin_date'] = ev.start.date().isoformat()
+  xd['checkout_date'] = ev.end.date().isoformat()
   return xd
 
 
@@ -18,7 +18,7 @@ def _enrich(ev: dict) -> dict:
 async def get_reservation(target_date: date):
   events = await Reservation(DEFAULT_CALENDAR_NAME).list(target_date, target_date)
   for ev in events:
-    if ev['start'].date() <= target_date < ev['end'].date():
+    if ev.start.date() <= target_date < ev.end.date():
       return _enrich(ev)
   return None
 
@@ -28,8 +28,8 @@ async def get_reservation_range(start_date: date, end_date: date):
   events = await Reservation(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
   result: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
   for ev in events:
-    checkin = ev['start'].date()
-    checkout = ev['end'].date()
+    checkin = ev.start.date()
+    checkout = ev.end.date()
     enriched = _enrich(ev)
     for d in iter_dates(start_date, end_date):
       if checkin <= d < checkout:

+ 13 - 13
room_cal/busy/__init__.py

@@ -1,5 +1,5 @@
 from datetime import date
-from room_cal.cal import CalendarBacked
+from room_cal.cal import StoreBacked
 from room_cal._utils import get_config
 
 DEFAULT_CALENDAR_NAME = 'Busy'
@@ -11,26 +11,26 @@ def _get_calendar_name() -> str:
   return get_config().global_.busy_calendar_name or DEFAULT_CALENDAR_NAME
 
 
-class BusyCal(CalendarBacked):
-  def __init__(self, calendar_name: str | None = None, *, cal=None):
+class BusyCal(StoreBacked):
+  def __init__(self, calendar_name: str | None = None, *, backend=None):
     name = calendar_name or _get_calendar_name()
-    super().__init__(name, cal=cal)
+    super().__init__(name, backend=backend)
 
-  async def list(self, start_date: date, end_date: date) -> list[dict]:
-    events = await self._cal.fetch_date_range(start_date, end_date)
-    return [e for e in events if e['all_day'] and e['title'] == _BUSY_TITLE]
+  async def list(self, start_date: date, end_date: date) -> list:
+    events = await self._fetch_date_range(start_date, end_date)
+    return [e for e in events if e.all_day and e.title == _BUSY_TITLE]
 
   async def set(self, target_date: date, *, existing=_UNSET) -> None:
     if existing is _UNSET:
       existing = await self._find(target_date)
     if existing:
       return
-    await self._cal.create_event(target_date, _BUSY_TITLE)
+    await self._create_all_day(target_date, _BUSY_TITLE)
 
-  async def delete(self, event: dict) -> None:
-    await self._cal.delete_event(event)
+  async def delete(self, event) -> None:
+    await self._backend.delete(event.uid)
 
-  async def _find(self, target_date: date) -> dict | None:
-    events = await self._cal.fetch_date_range(target_date, target_date)
-    matches = [e for e in events if e['all_day'] and e['title'] == _BUSY_TITLE]
+  async def _find(self, target_date: date):
+    events = await self._fetch_date_range(target_date, target_date)
+    matches = [e for e in events if e.all_day and e.title == _BUSY_TITLE]
     return matches[0] if matches else None

+ 140 - 5
room_cal/cal/__init__.py

@@ -7,6 +7,7 @@ from datetime import date, datetime, timedelta, timezone
 from icalendar import Calendar as _iCal, Event as _iEvent
 
 from room_cal._utils import data_home, read_env
+from room_cal.cal._backend import EventBackend, EventRecord
 
 _log = logging.getLogger(__name__)
 
@@ -65,7 +66,6 @@ def _parse_event(caldav_event) -> dict:
 
 
 def _build_ical(dtstart, dtend, title: str, description: str = '', uid: str = None, x_data: str = None) -> str:
-  """Build an iCal string for one event. Pass date objects for all-day, datetime for timed."""
   cal = _iCal()
   cal.add('prodid', '-//room_cal//EN')
   cal.add('version', '2.0')
@@ -228,8 +228,143 @@ def clear_caches() -> int:
   return len(_registry)
 
 
-class CalendarBacked:
-  """Base for objects backed by a single named CalDAV calendar."""
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal: 'Calendar | None' = None):
+# ── CalDAVBackend ─────────────────────────────────────────────────────────────
+
+def _dict_to_record(d: dict) -> EventRecord:
+  return EventRecord(
+    uid=d['uid'],
+    start=d['start'],
+    end=d['end'],
+    all_day=d['all_day'],
+    title=d['title'],
+    description=d['description'],
+    x_data=d['x_data'],
+    _meta={k: d[k] for k in ('url', 'etag', 'recurrence_id', '_vevent', '_raw_event') if k in d},
+  )
+
+
+class CalDAVBackend:
+  def __init__(self, cal: Calendar):
+    self._cal = cal
+
+  async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]:
+    events = await self._cal.fetch_events(start, end)
+    return [_dict_to_record(e) for e in events]
+
+  async def create(self, record: EventRecord) -> EventRecord:
+    if record.all_day:
+      dtstart = record.start.date()
+      dtend = record.end.date()
+    else:
+      dtstart = record.start
+      dtend = record.end
+    ical_str = _build_ical(dtstart, dtend, record.title, record.description,
+                           uid=record.uid, x_data=record.x_data)
+    async with await self._cal._client() as client:
+      cal = await self._cal._get_calendar(client)
+      saved = await cal.save_event(ical_str)
+    self._cal._event_cache.clear()
+    _log.info('create date=%s title=%r', record.start.date(), record.title)
+    return _dict_to_record(_parse_event(saved))
+
+  async def update(self, uid: str, *, title: str | None = None,
+                   description: str | None = None, x_data: str | None = None) -> EventRecord:
+    async with await self._cal._client() as client:
+      cal = await self._cal._get_calendar(client)
+      raw = await cal.event_by_uid(uid)
+      ical = raw.icalendar_instance
+      for component in ical.walk():
+        if component.name != 'VEVENT':
+          continue
+        if title is not None:
+          if 'SUMMARY' in component:
+            del component['SUMMARY']
+          component.add('summary', title)
+        if description is not None:
+          if 'DESCRIPTION' in component:
+            del component['DESCRIPTION']
+          component.add('description', description)
+        if x_data is not None:
+          if _X_DATA_PROP in component:
+            del component[_X_DATA_PROP]
+          component.add(_X_DATA_PROP, x_data)
+      raw.data = ical.to_ical().decode('utf-8')
+      await raw.save()
+    self._cal._event_cache.clear()
+    _log.info('update uid=%s', uid)
+    return _dict_to_record(_parse_event(raw))
+
+  async def delete(self, uid: str) -> None:
+    async with await self._cal._client() as client:
+      cal = await self._cal._get_calendar(client)
+      raw = await cal.event_by_uid(uid)
+      await raw.delete()
+    self._cal._event_cache.clear()
+    _log.info('delete uid=%s', uid)
+
+
+# ── Backend registry + factory ────────────────────────────────────────────────
+
+_backend_registry: dict[str, CalDAVBackend] = {}
+
+
+def get_backend(name: str = DEFAULT_CALENDAR_NAME) -> EventBackend:
+  """Return the shared EventBackend for the given calendar name.
+
+  Uses the backend type configured via the 'backend' key in config.json
+  ('caldav' by default, 'sqlite' for local SQLite storage).
+  """
+  from room_cal._utils import get_config, data_home as _data_home
+  cfg = get_config()
+  if cfg.backend == 'sqlite':
+    from room_cal.cal._sqlite import SQLiteBackend
+    return SQLiteBackend(_data_home() / 'room_cal.db', name)
+  if name not in _backend_registry:
+    _backend_registry[name] = CalDAVBackend(get_calendar(name))
+  return _backend_registry[name]
+
+
+# ── StoreBacked ───────────────────────────────────────────────────────────────
+
+class StoreBacked:
+  """Base for objects backed by a single named event backend."""
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *,
+               backend: 'EventBackend | None' = None):
     self._calendar_name = calendar_name
-    self._cal = cal if cal is not None else get_calendar(calendar_name)
+    self._backend: EventBackend = backend if backend is not None else get_backend(calendar_name)
+
+  async def _fetch_date_range(self, start_date: date, end_date: date) -> list[EventRecord]:
+    start = datetime(start_date.year, start_date.month, start_date.day)
+    end = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
+    return await self._backend.fetch_range(start, end)
+
+  async def _create_all_day(self, start_date: date, title: str, description: str = '',
+                             x_data: str | None = None, *,
+                             end_date: date | None = None) -> EventRecord:
+    end = end_date if end_date is not None else start_date + timedelta(days=1)
+    record = EventRecord(
+      uid=str(uuid.uuid4()),
+      start=datetime(start_date.year, start_date.month, start_date.day),
+      end=datetime(end.year, end.month, end.day),
+      all_day=True,
+      title=title,
+      description=description,
+      x_data=x_data,
+    )
+    return await self._backend.create(record)
+
+  async def _create_timed(self, start: datetime, end: datetime, title: str,
+                           description: str = '', x_data: str | None = None) -> EventRecord:
+    record = EventRecord(
+      uid=str(uuid.uuid4()),
+      start=start,
+      end=end,
+      all_day=False,
+      title=title,
+      description=description,
+      x_data=x_data,
+    )
+    return await self._backend.create(record)
+
+
+CalendarBacked = StoreBacked  # backward compat alias

+ 23 - 0
room_cal/cal/_backend.py

@@ -0,0 +1,23 @@
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Protocol
+
+
+@dataclass
+class EventRecord:
+  uid: str
+  start: datetime
+  end: datetime
+  all_day: bool
+  title: str
+  description: str
+  x_data: str | None
+  _meta: dict = field(default_factory=dict)
+
+
+class EventBackend(Protocol):
+  async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]: ...
+  async def create(self, record: EventRecord) -> EventRecord: ...
+  async def update(self, uid: str, *, title: str | None = None,
+                   description: str | None = None, x_data: str | None = None) -> EventRecord: ...
+  async def delete(self, uid: str) -> None: ...

+ 105 - 0
room_cal/cal/_sqlite.py

@@ -0,0 +1,105 @@
+import logging
+from datetime import datetime
+from pathlib import Path
+
+from room_cal.cal._backend import EventRecord
+
+_log = logging.getLogger(__name__)
+
+_CREATE_TABLE = '''
+CREATE TABLE IF NOT EXISTS events (
+  uid           TEXT NOT NULL,
+  calendar_name TEXT NOT NULL,
+  start         TEXT NOT NULL,
+  end           TEXT NOT NULL,
+  all_day       INTEGER NOT NULL,
+  title         TEXT NOT NULL,
+  description   TEXT NOT NULL DEFAULT '',
+  x_data        TEXT,
+  PRIMARY KEY (uid, calendar_name)
+)
+'''
+
+_COLS = 'uid, calendar_name, start, end, all_day, title, description, x_data'
+
+
+class SQLiteBackend:
+  def __init__(self, db_path: Path, calendar_name: str):
+    self._db_path = db_path
+    self._calendar_name = calendar_name
+
+  def _to_record(self, row) -> EventRecord:
+    uid, _, start, end, all_day, title, description, x_data = row
+    return EventRecord(
+      uid=uid,
+      start=datetime.fromisoformat(start),
+      end=datetime.fromisoformat(end),
+      all_day=bool(all_day),
+      title=title,
+      description=description,
+      x_data=x_data,
+    )
+
+  async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]:
+    import aiosqlite
+    async with aiosqlite.connect(self._db_path) as db:
+      await db.execute(_CREATE_TABLE)
+      cursor = await db.execute(
+        f'SELECT {_COLS} FROM events WHERE calendar_name = ? AND start < ? AND end > ? ORDER BY start',
+        (self._calendar_name, end.isoformat(), start.isoformat()),
+      )
+      rows = await cursor.fetchall()
+    return [self._to_record(r) for r in rows]
+
+  async def create(self, record: EventRecord) -> EventRecord:
+    import aiosqlite
+    async with aiosqlite.connect(self._db_path) as db:
+      await db.execute(_CREATE_TABLE)
+      await db.execute(
+        f'INSERT INTO events ({_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+        (record.uid, self._calendar_name, record.start.isoformat(), record.end.isoformat(),
+         int(record.all_day), record.title, record.description, record.x_data),
+      )
+      await db.commit()
+    _log.info('create date=%s title=%r', record.start.date(), record.title)
+    return record
+
+  async def update(self, uid: str, *, title: str | None = None,
+                   description: str | None = None, x_data: str | None = None) -> EventRecord:
+    import aiosqlite
+    async with aiosqlite.connect(self._db_path) as db:
+      await db.execute(_CREATE_TABLE)
+      if title is not None:
+        await db.execute(
+          'UPDATE events SET title = ? WHERE uid = ? AND calendar_name = ?',
+          (title, uid, self._calendar_name),
+        )
+      if description is not None:
+        await db.execute(
+          'UPDATE events SET description = ? WHERE uid = ? AND calendar_name = ?',
+          (description, uid, self._calendar_name),
+        )
+      if x_data is not None:
+        await db.execute(
+          'UPDATE events SET x_data = ? WHERE uid = ? AND calendar_name = ?',
+          (x_data, uid, self._calendar_name),
+        )
+      await db.commit()
+      cursor = await db.execute(
+        f'SELECT {_COLS} FROM events WHERE uid = ? AND calendar_name = ?',
+        (uid, self._calendar_name),
+      )
+      row = await cursor.fetchone()
+    _log.info('update uid=%s', uid)
+    return self._to_record(row)
+
+  async def delete(self, uid: str) -> None:
+    import aiosqlite
+    async with aiosqlite.connect(self._db_path) as db:
+      await db.execute(_CREATE_TABLE)
+      await db.execute(
+        'DELETE FROM events WHERE uid = ? AND calendar_name = ?',
+        (uid, self._calendar_name),
+      )
+      await db.commit()
+    _log.info('delete uid=%s', uid)

+ 20 - 20
room_cal/closed/__init__.py

@@ -1,7 +1,7 @@
 import json
 from dataclasses import dataclass, field
 from datetime import date
-from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import EventSource, safe_json_loads, filter_events, services as _services, service_name, source_line
 
 _PREFIX = 'Closed: '
@@ -32,64 +32,64 @@ def _build_description(closed_services: list[str], source: dict) -> str:
   return desc
 
 
-class Closed(CalendarBacked, EventSource):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal=None):
-    CalendarBacked.__init__(self, calendar_name, cal=cal)
+class Closed(StoreBacked, EventSource):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
+    StoreBacked.__init__(self, calendar_name, backend=backend)
     EventSource.__init__(self, 'closed')
 
   async def set(self, target_date: date, data: 'ClosedData', *, existing=_UNSET) -> None:
     """Create or update a 'Closed' all-day event. Deletes any existing event if data.services is empty.
 
-    Pass existing=<event> to skip the internal lookup when the caller already holds the event.
+    Pass existing=<EventRecord> to skip the internal lookup when the caller already holds the event.
     Pass existing=None to unconditionally create.
     """
     if existing is _UNSET:
       existing = await self._find(target_date)
     if not data.services:
       if existing:
-        await self._cal.delete_event(existing)
+        await self._backend.delete(existing.uid)
       return
     title = _build_title(data.services)
     description = _build_description(data.services, data.source)
     x_data = json.dumps({'closed_services': sorted(data.services), 'source': data.source})
     if existing:
-      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
-        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+      if title != existing.title or description != existing.description or x_data != existing.x_data:
+        await self._backend.update(existing.uid, title=title, description=description, x_data=x_data)
     else:
-      await self._cal.create_event(target_date, title, description, x_data=x_data)
+      await self._create_all_day(target_date, title, description, x_data)
 
   async def get(self, target_date: date) -> 'ClosedData | None':
     """Return ClosedData for the date, or None if no closure event exists."""
     event = await self._find(target_date)
     if event is None:
       return None
-    d = safe_json_loads(event['x_data'])
+    d = safe_json_loads(event.x_data)
     if d is None:
       return None
     return ClosedData(services=d.get('closed_services', []), source=d.get('source') or {})
 
-  async def list(self, start_date: date, end_date: date) -> list[dict]:
+  async def list(self, start_date: date, end_date: date) -> list:
     """Return all 'Closed' all-day events in the date range."""
-    events = await self._cal.fetch_date_range(start_date, end_date)
+    events = await self._fetch_date_range(start_date, end_date)
     return filter_events(events, _PREFIX, all_day=True)
 
-  async def get_range(self, start_date: date, end_date: date, *, events: list[dict] | None = None) -> 'ClosedList':
+  async def get_range(self, start_date: date, end_date: date, *, events: list | None = None) -> 'ClosedList':
     """Return {date: ClosedData} for all closed events in range.
 
-    Pass events to reuse a pre-fetched list() result and avoid a second CalDAV call.
+    Pass events to reuse a pre-fetched list() result and avoid a second backend call.
     """
     result: ClosedList = {}
     for e in (events if events is not None else await self.list(start_date, end_date)):
-      d = e['start'].date()
-      parsed = safe_json_loads(e['x_data'])
+      d = e.start.date()
+      parsed = safe_json_loads(e.x_data)
       if parsed is not None:
         result[d] = ClosedData(services=parsed.get('closed_services', []), source=parsed.get('source') or {})
     return result
 
-  async def delete(self, event: dict) -> None:
-    await self._cal.delete_event(event)
+  async def delete(self, event) -> None:
+    await self._backend.delete(event.uid)
 
-  async def _find(self, target_date: date) -> dict | None:
-    events = await self._cal.fetch_date_range(target_date, target_date)
+  async def _find(self, target_date: date):
+    events = await self._fetch_date_range(target_date, target_date)
     matches = filter_events(events, _PREFIX, all_day=True)
     return matches[0] if matches else None

+ 1 - 0
room_cal/config-all.json

@@ -41,6 +41,7 @@
       }
     }
   },
+  "backend": "caldav",
   "global": {
     "taxes": {
       "sales": 0.157

+ 18 - 18
room_cal/discount/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from datetime import date
 from enum import StrEnum
-from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, filter_events, source_line
 
 _PREFIX = 'Discount: '
@@ -38,11 +38,11 @@ def _build_description(discount_type: str, value: int, source: dict) -> str:
   return desc
 
 
-class Discount(CalendarBacked):
+class Discount(StoreBacked):
   async def set(self, target_date: date, data: DiscountData, *, existing=_UNSET) -> None:
     """Create or update a 'Discount' all-day event.
 
-    Pass existing=<event> to skip the internal lookup when the caller already holds the event.
+    Pass existing=<EventRecord> to skip the internal lookup when the caller already holds the event.
     Pass existing=None to unconditionally create.
     """
     title = _build_title(data.type, data.value)
@@ -51,49 +51,49 @@ class Discount(CalendarBacked):
     if existing is _UNSET:
       existing = await self._find(target_date)
     if existing:
-      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
-        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+      if title != existing.title or description != existing.description or x_data != existing.x_data:
+        await self._backend.update(existing.uid, title=title, description=description, x_data=x_data)
     else:
-      await self._cal.create_event(target_date, title, description, x_data=x_data)
+      await self._create_all_day(target_date, title, description, x_data)
 
   async def get(self, target_date: date) -> 'DiscountData | None':
     """Return DiscountData for the date, or None if no discount event exists."""
     event = await self._find(target_date)
     if event is None:
       return None
-    d = safe_json_loads(event['x_data'])
+    d = safe_json_loads(event.x_data)
     if d is None:
       return None
     return DiscountData(type=DiscountType(d['type']), value=d['value'], source=d.get('source') or {})
 
-  async def list(self, start_date: date, end_date: date) -> list[dict]:
+  async def list(self, start_date: date, end_date: date) -> list:
     """Return all 'Discount' all-day events in the date range."""
-    events = await self._cal.fetch_date_range(start_date, end_date)
+    events = await self._fetch_date_range(start_date, end_date)
     return filter_events(events, _PREFIX, all_day=True)
 
-  async def get_range(self, start_date: date, end_date: date, *, events: list[dict] | None = None) -> 'DiscountList':
+  async def get_range(self, start_date: date, end_date: date, *, events: list | None = None) -> 'DiscountList':
     """Return {date: DiscountData} for all discount events in range.
 
-    Pass events to reuse a pre-fetched list() result and avoid a second CalDAV call.
+    Pass events to reuse a pre-fetched list() result and avoid a second backend call.
     """
     result: DiscountList = {}
     for e in (events if events is not None else await self.list(start_date, end_date)):
-      d = e['start'].date()
-      parsed = safe_json_loads(e['x_data'])
+      d = e.start.date()
+      parsed = safe_json_loads(e.x_data)
       if parsed is not None:
         result[d] = DiscountData(type=DiscountType(parsed['type']), value=parsed['value'], source=parsed.get('source') or {})
     return result
 
-  async def delete(self, event: dict) -> None:
-    await self._cal.delete_event(event)
+  async def delete(self, event) -> None:
+    await self._backend.delete(event.uid)
 
   async def delete_on(self, target_date: date) -> None:
     """Delete the discount event on the given date, if any."""
     existing = await self._find(target_date)
     if existing:
-      await self._cal.delete_event(existing)
+      await self._backend.delete(existing.uid)
 
-  async def _find(self, target_date: date) -> dict | None:
-    events = await self._cal.fetch_date_range(target_date, target_date)
+  async def _find(self, target_date: date):
+    events = await self._fetch_date_range(target_date, target_date)
     matches = filter_events(events, _PREFIX, all_day=True)
     return matches[0] if matches else None

+ 26 - 23
room_cal/ical_feeds/__init__.py

@@ -2,6 +2,7 @@ import urllib.request
 from datetime import date, datetime, timedelta
 
 from room_cal._utils import CalendarEntry, EventSource, get_config
+from room_cal.cal._backend import EventRecord
 
 _source = EventSource('ical_feeds')
 
@@ -29,7 +30,7 @@ def _is_all_day(dt) -> bool:
   return isinstance(dt, date) and not isinstance(dt, datetime)
 
 
-def _parse_ical(raw_bytes: bytes, calendar_name: str) -> list[dict]:
+def _parse_ical(raw_bytes: bytes, calendar_name: str) -> list[EventRecord]:
   from icalendar import Calendar as _iCal
   cal = _iCal.from_ical(raw_bytes)
   events = []
@@ -43,20 +44,21 @@ def _parse_ical(raw_bytes: bytes, calendar_name: str) -> list[dict]:
     dtend_prop = component.get('DTEND') or component.get('DUE')
     dtend = dtend_prop.dt if dtend_prop else dtstart
 
-    events.append({
-      'calendar':    calendar_name,
-      'start':       _parse_dt(dtstart),
-      'end':         _parse_dt(dtend),
-      'all_day':     _is_all_day(dtstart),
-      'title':       str(component.get('SUMMARY', '')),
-      'description': str(component.get('DESCRIPTION', '')),
-      'uid':         str(component.get('UID', '')),
-    })
-  events.sort(key=lambda e: e['start'])
+    events.append(EventRecord(
+      uid=str(component.get('UID', '')),
+      start=_parse_dt(dtstart),
+      end=_parse_dt(dtend),
+      all_day=_is_all_day(dtstart),
+      title=str(component.get('SUMMARY', '')),
+      description=str(component.get('DESCRIPTION', '')),
+      x_data=None,
+      _meta={'calendar': calendar_name},
+    ))
+  events.sort(key=lambda e: e.start)
   return events
 
 
-async def _fetch_caldav(entry: CalendarEntry) -> list[dict]:
+async def _fetch_caldav(entry: CalendarEntry) -> list[EventRecord]:
   from room_cal.cal import Calendar
 
   calendar_name = entry.caldav
@@ -67,20 +69,21 @@ async def _fetch_caldav(entry: CalendarEntry) -> list[dict]:
   cal = Calendar(calendar_name)
   raw_events = await cal.fetch_events(start, end)
   return [
-    {
-      'calendar':    entry.name,
-      'start':       e['start'],
-      'end':         e['end'],
-      'all_day':     e['all_day'],
-      'title':       e['title'],
-      'description': e['description'],
-      'uid':         e['uid'],
-    }
+    EventRecord(
+      uid=e['uid'],
+      start=e['start'],
+      end=e['end'],
+      all_day=e['all_day'],
+      title=e['title'],
+      description=e['description'],
+      x_data=e.get('x_data'),
+      _meta={'calendar': entry.name},
+    )
     for e in raw_events
   ]
 
 
-async def fetch_calendar(entry: CalendarEntry) -> list[dict]:
+async def fetch_calendar(entry: CalendarEntry) -> list[EventRecord]:
   """Fetch one CalendarEntry (with either url or caldav) and return its events."""
   if entry.caldav is not None:
     return await _fetch_caldav(entry)
@@ -91,7 +94,7 @@ async def fetch_calendar(entry: CalendarEntry) -> list[dict]:
   return _parse_ical(raw, entry.name)
 
 
-async def fetch_all() -> dict[str, list[dict]]:
+async def fetch_all() -> dict[str, list[EventRecord]]:
   """Fetch all configured external calendars. Returns {name: [events]}."""
   results = {}
   for entry in get_config().calendars:

+ 16 - 16
room_cal/pricing/price_calendar.py

@@ -1,7 +1,7 @@
 import json
 from dataclasses import dataclass, field
 from datetime import date
-from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, filter_events, services as _services, get_config
 from room_cal.discount import DiscountType
 from . import calc
@@ -49,15 +49,15 @@ class PriceData:
 PriceList = dict[str, 'PriceData | None']
 
 
-class CalPrice(CalendarBacked):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME):
-    super().__init__(calendar_name)
+class CalPrice(StoreBacked):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
+    super().__init__(calendar_name, backend=backend)
     self._services = [s.key for s in _services()]
 
   async def _get_prices(self, target_date: date) -> 'PriceData | None':
-    events = await self._cal.fetch_date_range(target_date, target_date)
+    events = await self._fetch_date_range(target_date, target_date)
     for e in filter_events(events, _PREFIX, all_day=True):
-      d = safe_json_loads(e['x_data'])
+      d = safe_json_loads(e.x_data)
       if d is not None:
         return PriceData.from_dict(d, self._services)
     return None
@@ -67,19 +67,19 @@ class CalPrice(CalendarBacked):
     description = _build_description(prices)
     x_data = json.dumps(prices.to_dict())
     if existing is _UNSET:
-      events = await self._cal.fetch_date_range(target_date, target_date)
+      events = await self._fetch_date_range(target_date, target_date)
       matches = filter_events(events, _PREFIX, all_day=True, require_x_data=False)
       existing = matches[0] if matches else None
     if existing:
-      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
-        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+      if title != existing.title or description != existing.description or x_data != existing.x_data:
+        await self._backend.update(existing.uid, title=title, description=description, x_data=x_data)
     else:
-      await self._cal.create_event(target_date, title, description, x_data=x_data)
+      await self._create_all_day(target_date, title, description, x_data)
 
   async def set(self, target_date: date, base: float | int, *, existing=_UNSET, discount_rec=_UNSET) -> 'PriceData':
     """Compute and write a price event. Returns the PriceData that was written.
 
-    Pass existing=<event> to skip the internal event lookup.
+    Pass existing=<EventRecord> to skip the internal event lookup.
     Pass discount_rec=<DiscountData|None> to skip the per-day Discount.get call.
     """
     from room_cal.discount import Discount
@@ -114,9 +114,9 @@ class CalPrice(CalendarBacked):
     await self._put_prices(target_date, prices, existing=existing)
     return prices
 
-  async def list(self, start_date: date, end_date: date) -> list[dict]:
+  async def list(self, start_date: date, end_date: date) -> list:
     """Return all price all-day events in the date range."""
-    events = await self._cal.fetch_date_range(start_date, end_date)
+    events = await self._fetch_date_range(start_date, end_date)
     return filter_events(events, _PREFIX, all_day=True, require_x_data=False)
 
   async def get(self, start_date: date, end_date: date) -> 'PriceList':
@@ -126,12 +126,12 @@ class CalPrice(CalendarBacked):
     config) — no event is written. Use fill_prices action to persist defaults.
     """
     from room_cal._utils import iter_dates
-    events = await self._cal.fetch_date_range(start_date, end_date)
+    events = await self._fetch_date_range(start_date, end_date)
     by_date: PriceList = {}
     for ev in filter_events(events, _PREFIX, all_day=True):
-      d = ev['start'].date()
+      d = ev.start.date()
       if start_date <= d <= end_date:
-        parsed = safe_json_loads(ev['x_data'])
+        parsed = safe_json_loads(ev.x_data)
         if parsed is not None:
           by_date[str(d)] = PriceData.from_dict(parsed, self._services)
 

+ 19 - 19
room_cal/reservations/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from datetime import date, datetime
 from zoneinfo import ZoneInfo
-from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import EventSource, safe_json_loads, filter_events, service_name, get_config
 from room_cal._mnemonic import wifi_key as _gen_wifi_key, door_code as _gen_door_code
 
@@ -24,9 +24,9 @@ def _property_cfg():
   return get_config().global_.property
 
 
-class Reservation(CalendarBacked, EventSource):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal=None):
-    CalendarBacked.__init__(self, calendar_name, cal=cal)
+class Reservation(StoreBacked, EventSource):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
+    StoreBacked.__init__(self, calendar_name, backend=backend)
     EventSource.__init__(self, 'reservations')
 
   async def set(self, data: dict) -> None:
@@ -36,11 +36,11 @@ class Reservation(CalendarBacked, EventSource):
       raise ValueError(f"checkout_date ({checkout_date}) must be after checkin_date ({checkin_date})")
 
     existing = await self._find(data['reservation_id'], checkin_date, checkout_date)
-    if existing and (existing['start'].date() != checkin_date or existing['end'].date() != checkout_date):
-      await self._cal.delete_event(existing)
+    if existing and (existing.start.date() != checkin_date or existing.end.date() != checkout_date):
+      await self._backend.delete(existing.uid)
       existing = None
 
-    existing_xd = safe_json_loads(existing['x_data']) if existing else None
+    existing_xd = safe_json_loads(existing.x_data) if existing else None
     wk = data.get('wifi_key') or (existing_xd or {}).get('wifi_key') or _gen_wifi_key()
     dc = data.get('door_code') or (existing_xd or {}).get('door_code') or _gen_door_code()
 
@@ -56,20 +56,20 @@ class Reservation(CalendarBacked, EventSource):
     description = _build_description(data, wk, dc)
 
     if existing:
-      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
-        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+      if title != existing.title or description != existing.description or x_data != existing.x_data:
+        await self._backend.update(existing.uid, title=title, description=description, x_data=x_data)
     else:
       prop = _property_cfg()
       tz = ZoneInfo(prop.timezone)
       dtstart = datetime(checkin_date.year, checkin_date.month, checkin_date.day, prop.checkin_hour, 0, tzinfo=tz)
       dtend = datetime(checkout_date.year, checkout_date.month, checkout_date.day, prop.checkout_hour, 0, tzinfo=tz)
-      await self._cal.create_timed_event(dtstart, dtend, title, description, x_data=x_data)
+      await self._create_timed(dtstart, dtend, title, description, x_data)
 
   async def get(self, reservation_id: str, checkin_date: date, checkout_date: date) -> 'ReservationData | None':
     event = await self._find(reservation_id, checkin_date, checkout_date)
     if event is None:
       return None
-    d = safe_json_loads(event['x_data'])
+    d = safe_json_loads(event.x_data)
     if d is None:
       return None
     return ReservationData(
@@ -82,18 +82,18 @@ class Reservation(CalendarBacked, EventSource):
       door_code=d.get('door_code'),
     )
 
-  async def list(self, start_date: date, end_date: date) -> list[dict]:
-    """Return all guest reservation events in the CalDAV calendar within the date range."""
-    events = await self._cal.fetch_date_range(start_date, end_date)
+  async def list(self, start_date: date, end_date: date) -> list:
+    """Return all guest reservation events in the calendar within the date range."""
+    events = await self._fetch_date_range(start_date, end_date)
     return filter_events(events, _PREFIX, all_day=False)
 
-  async def delete(self, event: dict) -> None:
-    await self._cal.delete_event(event)
+  async def delete(self, event) -> None:
+    await self._backend.delete(event.uid)
 
-  async def _find(self, reservation_id: str, checkin_date: date, checkout_date: date) -> dict | None:
-    events = await self._cal.fetch_date_range(checkin_date, checkout_date)
+  async def _find(self, reservation_id: str, checkin_date: date, checkout_date: date):
+    events = await self._fetch_date_range(checkin_date, checkout_date)
     for e in filter_events(events, _PREFIX, all_day=False):
-      d = safe_json_loads(e['x_data'])
+      d = safe_json_loads(e.x_data)
       if d and d.get('reservation_id') == reservation_id:
         return e
     return None

+ 4 - 4
room_cal/rules/_utils.py

@@ -18,15 +18,15 @@ async def open_dates(start_date: date, end_date: date) -> set[date]:
     await res_cal.list(start_date, end_date),
   )
 
-  def _rule_sourced(e: dict) -> bool:
-    d = safe_json_loads(e.get('x_data', ''))
+  def _rule_sourced(e) -> bool:
+    d = safe_json_loads(e.x_data or '')
     domain = (d.get('source') or {}).get('domain') if d else None
     return domain == 'rules'
 
-  closed_dates: set[date] = {e['start'].date() for e in closed_events if not _rule_sourced(e)}
+  closed_dates: set[date] = {e.start.date() for e in closed_events if not _rule_sourced(e)}
   booked_dates: set[date] = set()
   for e in res_events:
-    for d in iter_dates(e['start'].date(), e['end'].date() - timedelta(days=1)):
+    for d in iter_dates(e.start.date(), e.end.date() - timedelta(days=1)):
       booked_dates.add(d)
 
   return {

+ 18 - 18
room_cal/user_cal/__init__.py

@@ -1,27 +1,27 @@
 import json
 from datetime import date
-from room_cal.cal import CalendarBacked
+from room_cal.cal import StoreBacked
 from room_cal._utils import EventSource, safe_json_loads, services as _services, service_name
 
 DEFAULT_CALENDAR_NAME = 'House'
 _ROOM_CLOSED_TITLE = 'Room Closed'
 
 
-class UserCal(CalendarBacked, EventSource):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal=None):
-    CalendarBacked.__init__(self, calendar_name, cal=cal)
+class UserCal(StoreBacked, EventSource):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
+    StoreBacked.__init__(self, calendar_name, backend=backend)
     EventSource.__init__(self, 'user_cal')
 
-  async def find_closed(self, start_date: date, end_date: date) -> list[dict]:
+  async def find_closed(self, start_date: date, end_date: date) -> list:
     """Return all 'Room Closed' events in the date range (timed or all-day)."""
-    events = await self._cal.fetch_date_range(start_date, end_date)
-    return [e for e in events if e['title'] == _ROOM_CLOSED_TITLE]
+    events = await self._fetch_date_range(start_date, end_date)
+    return [e for e in events if e.title == _ROOM_CLOSED_TITLE]
 
-  async def find_services(self, start_date: date, end_date: date) -> list[dict]:
+  async def find_services(self, start_date: date, end_date: date) -> list:
     """Return all service-named all-day events in the date range."""
     display_names = {s.name for s in _services()}
-    events = await self._cal.fetch_date_range(start_date, end_date)
-    return [e for e in events if e['all_day'] and e['title'] in display_names and e['x_data']]
+    events = await self._fetch_date_range(start_date, end_date)
+    return [e for e in events if e.all_day and e.title in display_names and e.x_data]
 
   async def set(self, service_key: str, reservation_id: str, checkin_date: date, checkout_date: date) -> None:
     """Create or update a service reservation all-day event spanning checkin through day before checkout."""
@@ -32,19 +32,19 @@ class UserCal(CalendarBacked, EventSource):
 
     existing = await self._find_by_reservation_id(reservation_id, checkin_date, checkout_date)
     if existing:
-      await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+      await self._backend.update(existing.uid, title=title, description=description, x_data=x_data)
     else:
-      await self._cal.create_event(checkin_date, title, description, x_data=x_data, end_date=checkout_date)
+      await self._create_all_day(checkin_date, title, description, x_data, end_date=checkout_date)
 
-  async def delete(self, event: dict) -> None:
-    await self._cal.delete_event(event)
+  async def delete(self, event) -> None:
+    await self._backend.delete(event.uid)
 
-  async def _find_by_reservation_id(self, reservation_id: str, checkin_date: date, checkout_date: date) -> dict | None:
-    events = await self._cal.fetch_date_range(checkin_date, checkout_date)
+  async def _find_by_reservation_id(self, reservation_id: str, checkin_date: date, checkout_date: date):
+    events = await self._fetch_date_range(checkin_date, checkout_date)
     for e in events:
-      if not e['all_day'] or not e['x_data']:
+      if not e.all_day or not e.x_data:
         continue
-      d = safe_json_loads(e['x_data'])
+      d = safe_json_loads(e.x_data)
       if d and d.get('reservation_id') == reservation_id:
         return e
     return None

+ 12 - 12
tests/test_action_chains.py

@@ -55,10 +55,10 @@ class _ClosedReadAPI(_MockAPI):
 
 
 async def _delete_price_event(cal: CalPrice, target: date) -> None:
-  """Delete the price CalDAV event for target (if present)."""
-  events = await cal._cal.fetch_date_range(target, target)
+  """Delete the price event for target (if present)."""
+  events = await cal._fetch_date_range(target, target)
   for e in filter_events(events, 'Price: $', all_day=True, require_x_data=False):
-    await cal._cal.delete_event(e)
+    await cal._backend.delete(e.uid)
 
 
 # ── fetch_user_cal ─────────────────────────────────────────────────────────────
@@ -73,14 +73,14 @@ async def test_fetch_user_cal_closed():
   scan_start = date.today() - timedelta(days=80)
   scan_end = date.today() - timedelta(days=75)
 
-  occupied = {e['start'].date() for e in await closed_cal.list(scan_start, scan_end)}
+  occupied = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
   free = [d for d in iter_dates(scan_start, scan_end) if d not in occupied]
   assert len(free) >= 1, "Need at least 1 free date in the 75–80-day-ago window"
 
   closed_date = free[0]
 
   try:
-    await ucal._cal.create_event(closed_date, 'Room Closed', '')
+    await ucal._create_all_day(closed_date, 'Room Closed')
 
     result = await fetch_user_cal(scan_start, scan_end)
 
@@ -99,7 +99,7 @@ async def test_fetch_user_cal_closed():
 
   finally:
     for ev in await ucal.find_closed(scan_start, scan_end):
-      if ev['start'].date() == closed_date:
+      if ev.start.date() == closed_date:
         await ucal.delete(ev)
     await closed_cal.set(closed_date, ClosedData(services=[]))
 
@@ -117,7 +117,7 @@ async def test_fetch_closed_writes_and_removes_closed_events():
   scan_start = date.today() - timedelta(days=75)
   scan_end = date.today() - timedelta(days=73)
 
-  existing = {e['start'].date() for e in await closed_cal.list(scan_start, scan_end)}
+  existing = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
   free = []
   for d in iter_dates(scan_start, scan_end):
     if d in existing:
@@ -159,7 +159,7 @@ async def test_fetch_closed_writes_and_removes_closed_events():
 
 @pytest.mark.asyncio
 async def test_fetch_prices_syncs_api_prices_to_caldav():
-  """fetch_prices writes API list prices (converted to net) to the CalDAV calendar."""
+  """fetch_prices writes API list prices (converted to net) to the calendar."""
   from room_cal.service_api import ServiceAPI
   api = ServiceAPI()
 
@@ -193,7 +193,7 @@ async def test_fetch_prices_syncs_api_prices_to_caldav():
 
 @pytest.mark.asyncio
 async def test_push_prices_calls_api_with_list_prices():
-  """push_prices reads CalDAV price events and calls api.set_prices with per-service list prices."""
+  """push_prices reads price events and calls api.set_prices with per-service list prices."""
   cal = CalPrice()
 
   # Find a past date that already has a price event in CalDAV
@@ -238,11 +238,11 @@ async def test_apply_discounts_pushes_discounted_prices():
 
   # Find a priced date with no existing discount and no reservation
   # (apply_discounts skips booked nights, so booked dates would always fail)
-  existing_discounts = {e['start'].date() for e in await discount_cal.list(scan_start, scan_end)}
+  existing_discounts = {e.start.date() for e in await discount_cal.list(scan_start, scan_end)}
   booked: set[date] = set()
   for e in await Reservation().list(scan_start, scan_end):
-    d = e['start'].date()
-    while d < e['end'].date():
+    d = e.start.date()
+    while d < e.end.date():
       booked.add(d)
       d += timedelta(days=1)
   target = None

+ 3 - 3
tests/test_closed.py

@@ -30,7 +30,7 @@ async def test_set_and_get_round_trip():
   scan_start = date.today() - timedelta(days=70)
   scan_end = date.today() - timedelta(days=50)
   existing_closures = await closed.list(scan_start, scan_end)
-  already_closed = {e['start'].date() for e in existing_closures}
+  already_closed = {e.start.date() for e in existing_closures}
 
   target = None
   d = scan_start
@@ -52,8 +52,8 @@ async def test_set_and_get_round_trip():
     # Verify the CalDAV event title
     event = await closed._find(target)
     assert event is not None
-    assert event['title'] == 'Closed: All'
-    assert event['description'] == 'AirBnB: Closed\nBooking.com: Closed\nSource: Test =>  test'
+    assert event.title == 'Closed: All'
+    assert event.description == 'AirBnB: Closed\nBooking.com: Closed\nSource: Test =>  test'
   finally:
     await closed.set(target, ClosedData(services=[]))
     assert await closed.get(target) is None, f"Closed event still present after restore on {target}"

+ 6 - 6
tests/test_discount.py

@@ -31,7 +31,7 @@ async def test_set_and_get_flat_round_trip():
   scan_start = date.today() - timedelta(days=50)
   scan_end = date.today() - timedelta(days=40)
   existing = await discount.list(scan_start, scan_end)
-  already_taken = {e['start'].date() for e in existing}
+  already_taken = {e.start.date() for e in existing}
 
   target = None
   d = scan_start
@@ -55,8 +55,8 @@ async def test_set_and_get_flat_round_trip():
 
     event = await discount._find(target)
     assert event is not None
-    assert event['title'] == 'Discount: $21'
-    assert event['description'] == 'Discount: $21\nSource: Test =>  test-flat'
+    assert event.title == 'Discount: $21'
+    assert event.description == 'Discount: $21\nSource: Test =>  test-flat'
   finally:
     await discount.delete_on(target)
     assert await discount.get(target) is None, f"Discount event still present after delete on {target}"
@@ -70,7 +70,7 @@ async def test_set_and_get_percent_round_trip():
   scan_start = date.today() - timedelta(days=40)
   scan_end = date.today() - timedelta(days=30)
   existing = await discount.list(scan_start, scan_end)
-  already_taken = {e['start'].date() for e in existing}
+  already_taken = {e.start.date() for e in existing}
 
   target = None
   d = scan_start
@@ -94,8 +94,8 @@ async def test_set_and_get_percent_round_trip():
 
     event = await discount._find(target)
     assert event is not None
-    assert event['title'] == 'Discount: 15%'
-    assert event['description'] == 'Discount: 15%\nSource: Test =>  test-pct'
+    assert event.title == 'Discount: 15%'
+    assert event.description == 'Discount: 15%\nSource: Test =>  test-pct'
   finally:
     await discount.delete_on(target)
     assert await discount.get(target) is None, f"Discount event still present after delete on {target}"

+ 5 - 7
tests/test_ical_feeds.py

@@ -1,20 +1,18 @@
 import pytest
 from room_cal._utils import CalendarEntry
 from room_cal.ical_feeds import fetch_calendar
-
-_REQUIRED_KEYS = {'calendar', 'start', 'end', 'all_day', 'title', 'description', 'uid'}
+from room_cal.cal._backend import EventRecord
 
 _FIRE_ENTRY = CalendarEntry(name='Fire', url='webcal://cal.4114.us/fire.ics')
 _HOUSE_ENTRY = CalendarEntry(name='House', caldav='House')
 
 
-def _assert_events(events: list, expected_calendar: str):
+def _assert_events(events: list[EventRecord], expected_calendar: str):
   assert isinstance(events, list), "fetch_calendar should return a list"
   for e in events:
-    missing = _REQUIRED_KEYS - e.keys()
-    assert not missing, f"Event missing keys: {missing}"
-    assert e['calendar'] == expected_calendar
-    assert e['end'] >= e['start']
+    assert isinstance(e, EventRecord), f"Expected EventRecord, got {type(e)}"
+    assert e._meta.get('calendar') == expected_calendar
+    assert e.end >= e.start
 
 
 @pytest.mark.asyncio

+ 14 - 14
tests/test_user_cal.py

@@ -20,30 +20,30 @@ async def test_set_and_find_services_round_trip():
     await ucal.set(service_key, reservation_id, checkin, checkout)
 
     events = await ucal.find_services(checkin, checkout)
-    match = next((e for e in events if json.loads(e['x_data']).get('reservation_id') == reservation_id), None)
+    match = next((e for e in events if json.loads(e.x_data).get('reservation_id') == reservation_id), None)
     assert match is not None, "Service event not found after set"
 
-    data = json.loads(match['x_data'])
+    data = json.loads(match.x_data)
     assert data['service_key'] == service_key
     assert data['reservation_id'] == reservation_id
 
-    assert match['all_day']
-    assert match['start'].date() == checkin
-    assert match['end'].date() == checkout  # DTEND is exclusive; last included day is checkout - 1
+    assert match.all_day
+    assert match.start.date() == checkin
+    assert match.end.date() == checkout  # DTEND is exclusive; last included day is checkout - 1
 
     svc_name = _service_name(service_key)
-    assert match['title'] == svc_name
-    assert match['description'] == f"{svc_name}: {reservation_id}"
+    assert match.title == svc_name
+    assert match.description == f"{svc_name}: {reservation_id}"
   finally:
     events = await ucal.find_services(checkin, checkout)
     for e in events:
       try:
-        if json.loads(e['x_data']).get('reservation_id') == reservation_id:
+        if json.loads(e.x_data).get('reservation_id') == reservation_id:
           await ucal.delete(e)
       except (ValueError, TypeError):
         pass
     remaining = await ucal.find_services(checkin, checkout)
-    leftover = [e for e in remaining if json.loads(e.get('x_data') or '{}').get('reservation_id') == reservation_id]
+    leftover = [e for e in remaining if json.loads(e.x_data or '{}').get('reservation_id') == reservation_id]
     assert not leftover, "Service event still present after cleanup"
 
 
@@ -63,13 +63,13 @@ async def test_set_is_idempotent():
     await ucal.set(service_key, reservation_id, checkin, checkout)
 
     events = await ucal.find_services(checkin, checkout)
-    matches = [e for e in events if json.loads(e['x_data']).get('reservation_id') == reservation_id]
+    matches = [e for e in events if json.loads(e.x_data).get('reservation_id') == reservation_id]
     assert len(matches) == 1, f"Expected 1 event, found {len(matches)}"
   finally:
     events = await ucal.find_services(checkin, checkout)
     for e in events:
       try:
-        if json.loads(e['x_data']).get('reservation_id') == reservation_id:
+        if json.loads(e.x_data).get('reservation_id') == reservation_id:
           await ucal.delete(e)
       except (ValueError, TypeError):
         pass
@@ -90,17 +90,17 @@ async def test_find_closed_returns_only_room_closed():
     await ucal.set(service_key, reservation_id, checkin, checkout)
 
     closed_events = await ucal.find_closed(checkin, checkout)
-    assert all(e['title'] == 'Room Closed' for e in closed_events), \
+    assert all(e.title == 'Room Closed' for e in closed_events), \
       "find_closed returned an event that is not titled 'Room Closed'"
 
     service_events = await ucal.find_services(checkin, checkout)
-    titles = {e['title'] for e in service_events}
+    titles = {e.title for e in service_events}
     assert 'Room Closed' not in titles, "find_services returned a 'Room Closed' event"
   finally:
     events = await ucal.find_services(checkin, checkout)
     for e in events:
       try:
-        if json.loads(e['x_data']).get('reservation_id') == reservation_id:
+        if json.loads(e.x_data).get('reservation_id') == reservation_id:
           await ucal.delete(e)
       except (ValueError, TypeError):
         pass

+ 36 - 17
tests/test_utils.py

@@ -2,6 +2,7 @@ import pytest
 from datetime import date, datetime, timedelta
 from pathlib import Path
 from room_cal._utils import safe_json_loads, iter_dates, event_dates, filter_events, read_env
+from room_cal.cal._backend import EventRecord
 
 
 # --- safe_json_loads ---
@@ -59,18 +60,29 @@ def test_iter_dates_consecutive():
 
 # --- event_dates ---
 
-def _allday(start: date, end: date, **kw) -> dict:
+def _allday(start: date, end: date) -> EventRecord:
   """All-day event; end is exclusive (CalDAV convention)."""
-  return {
-    'start': datetime(start.year, start.month, start.day),
-    'end':   datetime(end.year,   end.month,   end.day),
-    'all_day': True,
-    **kw,
-  }
-
-
-def _timed(start: datetime, end: datetime, **kw) -> dict:
-  return {'start': start, 'end': end, 'all_day': False, **kw}
+  return EventRecord(
+    uid='test-uid',
+    start=datetime(start.year, start.month, start.day),
+    end=datetime(end.year, end.month, end.day),
+    all_day=True,
+    title='',
+    description='',
+    x_data=None,
+  )
+
+
+def _timed(start: datetime, end: datetime) -> EventRecord:
+  return EventRecord(
+    uid='test-uid',
+    start=start,
+    end=end,
+    all_day=False,
+    title='',
+    description='',
+    x_data=None,
+  )
 
 
 def test_event_dates_all_day_single():
@@ -115,17 +127,24 @@ def test_event_dates_timed_event():
 
 # --- filter_events ---
 
-def _ev(title: str, *, all_day=True, x_data='{}') -> dict:
+def _ev(title: str, *, all_day=True, x_data='{}') -> EventRecord:
   d = datetime(2025, 1, 1)
-  return {'title': title, 'all_day': all_day, 'x_data': x_data,
-          'start': d, 'end': d + timedelta(days=1)}
+  return EventRecord(
+    uid='test-uid',
+    start=d,
+    end=d + timedelta(days=1),
+    all_day=all_day,
+    title=title,
+    description='',
+    x_data=x_data,
+  )
 
 
 def test_filter_events_prefix_match():
   events = [_ev('Price: $100'), _ev('Other: thing'), _ev('Price: $200')]
   result = filter_events(events, 'Price: ')
   assert len(result) == 2
-  assert all(e['title'].startswith('Price: ') for e in result)
+  assert all(e.title.startswith('Price: ') for e in result)
 
 
 def test_filter_events_no_match():
@@ -137,14 +156,14 @@ def test_filter_events_all_day_true():
   events = [_ev('Price: $100', all_day=True), _ev('Price: $200', all_day=False)]
   result = filter_events(events, 'Price: ', all_day=True)
   assert len(result) == 1
-  assert result[0]['all_day'] is True
+  assert result[0].all_day is True
 
 
 def test_filter_events_all_day_false():
   events = [_ev('Price: $100', all_day=True), _ev('Price: $200', all_day=False)]
   result = filter_events(events, 'Price: ', all_day=False)
   assert len(result) == 1
-  assert result[0]['all_day'] is False
+  assert result[0].all_day is False
 
 
 def test_filter_events_all_day_none_returns_both():