From 36cd08c236ad7dbbfd39f5d6d0c2929d4dcc46e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 20:19:49 -0400 Subject: [PATCH 1/2] feat(events): expose recurrence presets in EventSlideOver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Repeat" select (None / Daily / Weekly / Monthly / Yearly) that reads/writes the existing Event.recurrence RRULE. CalDAV-imported rules with extra parts (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) surface as a disabled "Custom" option with the raw rule shown read-only — visible but preserved unless the user explicitly picks a preset to replace it. EventUpdatePayload.recurrence is now string | null so we can clear via PATCH; backend service already treats null as "clear" (recurrence is in the nullable set in update_event). Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/client.ts | 2 +- frontend/src/components/EventSlideOver.vue | 68 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 29bf322..f6e0819 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -545,7 +545,7 @@ export interface EventUpdatePayload { description?: string; location?: string; color?: string; - recurrence?: string; + recurrence?: string | null; project_id?: number; } diff --git a/frontend/src/components/EventSlideOver.vue b/frontend/src/components/EventSlideOver.vue index 4943a9e..a962e12 100644 --- a/frontend/src/components/EventSlideOver.vue +++ b/frontend/src/components/EventSlideOver.vue @@ -37,6 +37,36 @@ const description = ref(""); const location = ref(""); const color = ref(""); const projectId = ref(null); +const recurrence = ref(""); + +// Preset RRULE strings. The select binds to `recurrencePreset`, which writes +// through to `recurrence`. CalDAV-imported rules with extra parts +// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw +// string is shown read-only below the select. +const RECURRENCE_PRESETS: Record = { + none: "", + daily: "FREQ=DAILY", + weekly: "FREQ=WEEKLY", + monthly: "FREQ=MONTHLY", + yearly: "FREQ=YEARLY", +}; + +const recurrencePreset = computed({ + get() { + const r = (recurrence.value || "").trim(); + if (!r) return "none"; + for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) { + if (val && val === r) return key; + } + return "custom"; + }, + set(key: string) { + if (key === "custom") return; // no-op; can't pick custom from dropdown + recurrence.value = RECURRENCE_PRESETS[key] ?? ""; + }, +}); + +const isCustomRecurrence = computed(() => recurrencePreset.value === "custom"); function dateFromIso(iso: string): string { const d = new Date(iso); @@ -115,6 +145,7 @@ function resetForm() { location.value = props.event.location || ""; color.value = props.event.color || ""; projectId.value = props.event.project_id; + recurrence.value = props.event.recurrence || ""; _lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60; if (_lastDurationMin <= 0) _lastDurationMin = 60; } else { @@ -130,6 +161,7 @@ function resetForm() { location.value = ""; color.value = ""; projectId.value = null; + recurrence.value = ""; _lastDurationMin = 60; } deleteConfirm.value = false; @@ -257,6 +289,7 @@ async function save() { location: location.value, color: color.value, project_id: projectId.value ?? undefined, + recurrence: recurrence.value || null, }; const updated = await updateEvent(props.event.id, payload); emit("updated", updated); @@ -270,6 +303,7 @@ async function save() { location: location.value, color: color.value, project_id: projectId.value ?? undefined, + recurrence: recurrence.value || undefined, }; const created = await createEvent(payload); emit("created", created); @@ -374,6 +408,24 @@ async function doDelete() { + +
+ + +

+ Custom rule: {{ recurrence }} +
+ Picking a preset will replace this rule. +

+
+
@@ -539,6 +591,22 @@ async function doDelete() { color: var(--color-text-secondary); } +.recurrence-custom-hint { + margin: 4px 0 0; + font-size: 0.75rem; + color: var(--color-text-secondary); + line-height: 1.4; +} +.recurrence-custom-hint code { + background: var(--color-input-bg, #111113); + border: 1px solid var(--color-border, #2a2b30); + border-radius: 4px; + padding: 1px 5px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.72rem; + color: var(--color-text, #e8e9f0); +} + .toggle-btn { background: var(--color-input-bg, #111113); border: 1px solid var(--color-border, #2a2b30); From c33cab70207683ddbf352777c6953c85a307b02e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 20:37:23 -0400 Subject: [PATCH 2/2] fix(journal): wire weather refresh on config save; drop orphaned cache rows Two related gaps in the journal weather panel: 1. Saving locations via PUT /journal/config didn't trigger a weather fetch, so newly-entered sites had no cache row (or a stale one) until the user manually clicked the panel's refresh button. The panel rendered "two sites with empty values" against pre-existing cache rows that no longer matched what the user had configured. 2. get_cached_weather_rows returned every WeatherCache row for the user regardless of whether the location was still in journal_config. Briefing-era rows survived migration 0040 (which only deleted the briefing_config setting, not the cache table) and showed up as ghost tabs in the UI. Changes: - get_cached_weather_rows accepts an optional valid_keys filter; rows whose location_key is not in the set are excluded. - routes/journal.py: - put_config kicks off a background refresh_location_cache for any saved location with valid lat/lon. - GET /weather and POST /weather/refresh both pass valid_keys derived from the current config so orphaned rows don't surface. - services/journal_prep.py filters the weather section to currently- configured locations as well; uses a lazy import of get_journal_config to avoid a cycle (journal_scheduler imports journal_prep). 153 tests pass. Co-Authored-By: Claude Opus 4.7 --- src/fabledassistant/routes/journal.py | 50 +++++++++++++++++++- src/fabledassistant/services/journal_prep.py | 10 +++- src/fabledassistant/services/weather.py | 21 ++++++-- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py index 8c4a6b5..462e840 100644 --- a/src/fabledassistant/routes/journal.py +++ b/src/fabledassistant/routes/journal.py @@ -66,6 +66,17 @@ async def _resolve_config(user_id: int) -> dict: return {**DEFAULT_JOURNAL_CONFIG, **config} +def _valid_location_keys(cfg: dict) -> set[str]: + """Keys in ``cfg.locations`` that have a usable lat/lon. Anything else + (orphaned cache rows, locations the user typed but didn't geocode) is + excluded so it can't render as a fake site in the UI.""" + locations = cfg.get("locations") or {} + return { + key for key, loc in locations.items() + if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None + } + + @journal_bp.get("/config") @login_required async def get_config(): @@ -82,9 +93,41 @@ async def put_config(): return jsonify({"error": "config must be an object"}), 400 await set_setting(user_id, "journal_config", json.dumps(body)) await update_user_schedule(user_id) + + # Trigger a background weather refresh for any newly-saved location with + # valid lat/lon. Without this, the cache row for the location doesn't + # exist (or stays stale) until the user clicks the manual refresh button, + # so the journal weather panel renders empty for newly-entered sites. + valid_locs = [ + (key, loc) + for key, loc in (body.get("locations") or {}).items() + if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None + ] + if valid_locs: + asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs)) + return jsonify({"ok": True}) +async def _refresh_locations_in_background( + user_id: int, locations: list[tuple[str, dict]] +) -> None: + for key, loc in locations: + try: + await weather_svc.refresh_location_cache( + user_id=user_id, + location_key=key, + location_label=loc.get("label", key), + lat=loc["lat"], + lon=loc["lon"], + ) + except Exception: + logger.warning( + "Post-save weather refresh failed for user %d / %s", + user_id, key, exc_info=True, + ) + + @journal_bp.get("/today") @login_required async def get_today(): @@ -258,7 +301,9 @@ async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> No @login_required async def get_weather(): user_id = get_current_user_id() - rows = await weather_svc.get_cached_weather_rows(user_id) + cfg = await _resolve_config(user_id) + valid_keys = _valid_location_keys(cfg) + rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys) temp_unit = await _journal_temp_unit(user_id) # Kick off a best-effort background refresh for stale rows so the next page @@ -318,7 +363,8 @@ async def refresh_weather(): ) except Exception: logger.warning("Failed to refresh weather for %s", key, exc_info=True) - rows = await weather_svc.get_cached_weather_rows(user_id) + valid_keys = _valid_location_keys(cfg) + rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys) cards = [ card for row in rows if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py index 73734ac..f1d9dcf 100644 --- a/src/fabledassistant/services/journal_prep.py +++ b/src/fabledassistant/services/journal_prep.py @@ -178,7 +178,15 @@ async def gather_daily_sections( sections["events"] = [] try: - weather_rows = await get_cached_weather_rows(user_id) + # Lazy import: journal_scheduler imports this module for prep generation, + # so a top-level import would cycle. + from fabledassistant.services.journal_scheduler import get_journal_config + cfg = await get_journal_config(user_id) + valid_weather_keys = { + key for key, loc in (cfg.get("locations") or {}).items() + if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None + } + weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys) sections["weather"] = [w.to_dict() for w in weather_rows] except Exception: logger.exception("daily_prep weather section failed for user %d", user_id) diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py index 34f7eb3..46051a1 100644 --- a/src/fabledassistant/services/weather.py +++ b/src/fabledassistant/services/weather.py @@ -232,12 +232,23 @@ def parse_weather_card_data( } -async def get_cached_weather_rows(user_id: int) -> list: - """Return raw WeatherCache ORM rows for a user (for card parsing).""" +async def get_cached_weather_rows( + user_id: int, + valid_keys: set[str] | None = None, +) -> list: + """Return raw WeatherCache ORM rows for a user (for card parsing). + + If ``valid_keys`` is provided, only rows whose ``location_key`` is in the + set are returned. This is how callers drop orphaned cache rows whose + location is no longer in the user's ``journal_config.locations`` (e.g. + leftovers from the briefing era, or a location that's been removed). + Passing an empty set returns no rows. + """ async with async_session() as session: - result = await session.execute( - select(WeatherCache).where(WeatherCache.user_id == user_id) - ) + stmt = select(WeatherCache).where(WeatherCache.user_id == user_id) + if valid_keys is not None: + stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys))) + result = await session.execute(stmt) return list(result.scalars().all())