From 91ae434e7cc282e96bd983c806a1ddc011d6ecaf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 14 Jul 2026 20:44:44 +0200 Subject: [PATCH] Add per-setting cross-device sync flags to user settings Introduce an account-wide sync-flag map that is the single server-side authority for which settings each client sends/fetches, plus value columns for two newly-syncable device-local prefs. - models.UserSettings: sync_flags (JSON), cache_months, month_view_paged - main._migrate(): idempotent ALTER TABLE for the three new columns - settings_router: DEFAULT_SYNC map + _resolve_sync_flags(); GET returns fully-resolved sync_flags (+ cache_months, month_view_paged); PUT accepts and merges a partial sync_flags map and the two new value fields Rollout defaults: settings that already lived on the server sync ON; the four newly-syncable prefs (language, share icon, cache range, month paging) sync OFF until the user opts in. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 14 +++++++ backend/models.py | 10 +++++ backend/routers/settings_router.py | 65 ++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/backend/main.py b/backend/main.py index 48fa0f1..01d28fe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -225,6 +225,20 @@ def _migrate(): except Exception: pass + # Per-setting cross-device sync: value columns for the two newly syncable + # device-local prefs, plus the JSON map of which settings sync. + for col, ddl in ( + ("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"), + ("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"), + ("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"), + ): + try: + conn.execute(text(ddl)) + conn.commit() + logging.info("Migration: added %s to user_settings", col) + except Exception: + pass + # CalDAV publishing of local calendars (opt-in, secret token URL). for col, ddl in ( ("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"), diff --git a/backend/models.py b/backend/models.py index 483cdff..8b2fb7b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -113,6 +113,16 @@ class UserSettings(Base): default_event_duration_minutes = Column(Integer, default=60) # Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups. share_calendar_icon = Column(String(16), nullable=True) + # How many months around the visible range clients preload/cache. Device-local + # by default (only shared when its sync flag is on). + cache_months = Column(Integer, default=3) + # Whether the month view uses horizontal paging (swipe) instead of a vertical + # scroll feed. Device-local by default (only shared when its sync flag is on). + month_view_paged = Column(Boolean, default=False) + # Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall + # back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is + # the single authority for which settings each client sends/fetches. + sync_flags = Column(Text, nullable=True) user = relationship("User", back_populates="settings") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index a317846..9fa827f 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -1,3 +1,4 @@ +import json from typing import Optional from fastapi import APIRouter, Depends, HTTPException @@ -11,6 +12,48 @@ from database import get_db router = APIRouter() +# Which settings can sync across a user's devices, and the default flag applied to +# an existing/new account until the user overrides it. This map is the single +# authority: GET returns the fully-resolved flags so no client duplicates default +# logic. Rollout rule: settings that already lived on the server default to True; +# the four newly-syncable device-local prefs default to False. +DEFAULT_SYNC = { + "default_view": True, + "week_start_day": True, + "dim_past_events": True, + "hour_height": True, + "primary_color": True, + "accent_color": True, + "today_color": True, + "text_color": True, + "line_color": True, + "bg_color": True, + "month_divider_color": True, + "month_label_color": True, + "default_event_duration_minutes": True, + "default_reminder_minutes": True, + "language": False, + "share_calendar_icon": False, + "cache_months": False, + "month_view_paged": False, +} + + +def _resolve_sync_flags(s: models.UserSettings) -> dict: + """Fully-resolved {key: bool} for every syncable setting: stored overrides on + top of DEFAULT_SYNC, junk keys dropped.""" + stored = {} + if s.sync_flags: + try: + stored = json.loads(s.sync_flags) or {} + except (ValueError, TypeError): + stored = {} + return { + key: bool(stored[key]) if key in stored else default + for key, default in DEFAULT_SYNC.items() + } + + class SettingsUpdate(BaseModel): default_view: Optional[str] = None week_start_day: Optional[str] = None @@ -32,6 +75,9 @@ class SettingsUpdate(BaseModel): default_reminder_minutes: Optional[int] = None # null = off default_event_duration_minutes: Optional[int] = None share_calendar_icon: Optional[str] = None + cache_months: Optional[int] = None + month_view_paged: Optional[bool] = None + sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map def _settings_dict(s: models.UserSettings) -> dict: @@ -56,6 +102,9 @@ def _settings_dict(s: models.UserSettings) -> dict: "default_reminder_minutes": s.default_reminder_minutes, "default_event_duration_minutes": s.default_event_duration_minutes or 60, "share_calendar_icon": s.share_calendar_icon, + "cache_months": s.cache_months or 3, + "month_view_paged": bool(s.month_view_paged), + "sync_flags": _resolve_sync_flags(s), } @@ -115,6 +164,22 @@ def update_settings( # keep the previous behaviour where a null/missing value is ignored. NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"} update_data = data.model_dump(exclude_unset=True) + + # Merge sync-flag overrides into the stored account-wide JSON map. Only known + # syncable keys are kept; a partial map leaves untouched flags as they were. + if "sync_flags" in update_data: + incoming = update_data.pop("sync_flags") or {} + current = {} + if settings.sync_flags: + try: + current = json.loads(settings.sync_flags) or {} + except (ValueError, TypeError): + current = {} + for key, val in incoming.items(): + if key in DEFAULT_SYNC: + current[key] = bool(val) + settings.sync_flags = json.dumps(current) + for field, value in update_data.items(): if field in NULLABLE_OVERRIDES: setattr(settings, field, value or None)