feat(web): theme import/export, more themable colors, hideable local/ical calendars

Theme:
- Export/import themes via UI as <date>_<time>.theme (JSON, readable keys,
  link to THEME.md). Import is partial-aware: only present params are written
  (server accepts partial); prompts before importing files with unknown params.
- Dynamic favicon + theme-color tinted to the primary colour on load/save.
- New themable colours: general hover-highlight, day hover/selected/bg,
  today background, plus two unified sidebar action-icon colours
  (inactive/active) covering bell, hide, delete and read-only icons.
- All new colours are per-setting syncable; documented in THEME.md.

UX:
- Styled confirm dialog (#modal-confirm) replaces window.confirm() for
  calendar delete and account disconnect.
- Birthday/local calendars and iCal subscriptions can now be hidden from the
  sidebar via Settings (new sidebar_hidden column + hide toggle).

Backend: additive nullable columns + idempotent migrations for user_settings
colours and local_calendars/ical_subscriptions.sidebar_hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-20 13:20:30 +02:00
parent 12c869451b
commit 6316ed3a6b
15 changed files with 475 additions and 47 deletions

View File

@@ -1,5 +1,8 @@
# Settings sync contract (Web / iOS / Android)
> For a human-facing description of each theme colour parameter (and the
> `.theme` import/export format), see [../THEME.md](../THEME.md).
Per-setting, cross-device synchronisation of user settings. The **server is the
sole authority** for *which* settings sync; clients must not duplicate that logic.
This document is the shared contract all three clients implement identically.

View File

@@ -240,6 +240,29 @@ def _migrate():
except Exception:
pass
# Fine-grained web element theme colours (all optional overrides).
for col in (
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color",
"day_bg_color", "today_bg_color",
):
try:
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
# from the sidebar, matching caldav/google/ha.
for tbl in ("local_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to %s", tbl)
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"),

View File

@@ -103,6 +103,15 @@ class UserSettings(Base):
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
# NULL = derive from bg_color. Device-local by default (not synced).
surface_color = Column(String(7), nullable=True)
# Fine-grained web element colours (all optional overrides; NULL = derive the
# previous default). See frontend THEME.md for what each one paints.
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
day_selected_color = Column(String(7), nullable=True) # selected day background
day_bg_color = Column(String(7), nullable=True) # normal day background
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
# How this user's private events appear to other group members:
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
private_event_visibility = Column(String(10), default="busy")
@@ -158,6 +167,8 @@ class LocalCalendar(Base):
name = Column(String(100), nullable=False)
color = Column(String(7), default="#34a853")
enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this calendar generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
@@ -221,6 +232,8 @@ class ICalSubscription(Base):
url = Column(String(1000), nullable=False)
color = Column(String(7), default="#46bdc6")
enabled = Column(Boolean, default=True)
# Hidden from the sidebar calendar list (still subscribed; just not shown).
sidebar_hidden = Column(Boolean, default=False, nullable=False)
# Whether events of this subscription generate reminders/notifications on clients.
reminders_enabled = Column(Boolean, default=True, nullable=False)
refresh_minutes = Column(Integer, default=60)

View File

@@ -31,6 +31,7 @@ class SubscriptionUpdate(BaseModel):
url: Optional[str] = None
color: Optional[str] = None
enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
refresh_minutes: Optional[int] = None
reminders_enabled: Optional[bool] = None
@@ -42,6 +43,7 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
"url": sub.url,
"color": sub.color,
"enabled": sub.enabled,
"sidebar_hidden": bool(sub.sidebar_hidden),
"reminders_enabled": bool(sub.reminders_enabled),
"refresh_minutes": sub.refresh_minutes,
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
@@ -273,6 +275,8 @@ def update_subscription(
sub.color = data.color
if data.enabled is not None:
sub.enabled = data.enabled
if data.sidebar_hidden is not None:
sub.sidebar_hidden = data.sidebar_hidden
if data.refresh_minutes is not None:
sub.refresh_minutes = data.refresh_minutes
if data.reminders_enabled is not None:

View File

@@ -33,6 +33,7 @@ class CalendarUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
enabled: Optional[bool] = None
sidebar_hidden: Optional[bool] = None
reminders_enabled: Optional[bool] = None
caldav_published: Optional[bool] = None
is_birthday: Optional[bool] = None
@@ -88,6 +89,7 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
# A recipient's own colour for a shared calendar wins over the owner's.
"color": color_override or cal.color,
"enabled": cal.enabled,
"sidebar_hidden": bool(cal.sidebar_hidden),
"reminders_enabled": bool(cal.reminders_enabled),
"caldav_published": bool(cal.caldav_published),
"is_birthday": bool(cal.is_birthday),
@@ -265,6 +267,8 @@ def update_calendar(
cal.color = data.color
if data.enabled is not None:
cal.enabled = data.enabled
if data.sidebar_hidden is not None:
cal.sidebar_hidden = data.sidebar_hidden
if data.reminders_enabled is not None:
cal.reminders_enabled = data.reminders_enabled
if data.is_birthday is not None:

View File

@@ -37,6 +37,13 @@ DEFAULT_SYNC = {
"cache_months": False,
"month_view_paged": False,
"surface_color": False,
"hover_highlight_color": True,
"icon_inactive_color": True,
"icon_active_color": True,
"day_hover_color": True,
"day_selected_color": True,
"day_bg_color": True,
"today_bg_color": True,
}
@@ -72,6 +79,13 @@ class SettingsUpdate(BaseModel):
line_color: Optional[str] = None
bg_color: Optional[str] = None
surface_color: Optional[str] = None
hover_highlight_color: Optional[str] = None
icon_inactive_color: Optional[str] = None
icon_active_color: Optional[str] = None
day_hover_color: Optional[str] = None
day_selected_color: Optional[str] = None
day_bg_color: Optional[str] = None
today_bg_color: Optional[str] = None
private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off
@@ -100,6 +114,13 @@ def _settings_dict(s: models.UserSettings) -> dict:
"line_color": s.line_color,
"bg_color": s.bg_color,
"surface_color": s.surface_color,
"hover_highlight_color": s.hover_highlight_color,
"icon_inactive_color": s.icon_inactive_color,
"icon_active_color": s.icon_active_color,
"day_hover_color": s.day_hover_color,
"day_selected_color": s.day_selected_color,
"day_bg_color": s.day_bg_color,
"today_bg_color": s.today_bg_color,
"private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes,
@@ -165,7 +186,7 @@ def update_settings(
# For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_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