Merge pull request 'Release v26.04.29.4 — recurrence UI + journal weather wiring fixes' (#48) from dev into main

This commit was merged in pull request #48.
This commit is contained in:
2026-04-30 02:08:19 +00:00
5 changed files with 142 additions and 9 deletions
+1 -1
View File
@@ -545,7 +545,7 @@ export interface EventUpdatePayload {
description?: string;
location?: string;
color?: string;
recurrence?: string;
recurrence?: string | null;
project_id?: number;
}
@@ -37,6 +37,36 @@ const description = ref("");
const location = ref("");
const color = ref("");
const projectId = ref<number | null>(null);
const recurrence = ref<string>("");
// 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<string, string> = {
none: "",
daily: "FREQ=DAILY",
weekly: "FREQ=WEEKLY",
monthly: "FREQ=MONTHLY",
yearly: "FREQ=YEARLY",
};
const recurrencePreset = computed<string>({
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() {
</div>
</div>
<!-- Recurrence -->
<div class="form-field">
<label class="form-label">Repeat</label>
<select v-model="recurrencePreset" class="form-input">
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
</select>
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
Custom rule: <code>{{ recurrence }}</code>
<br />
<span class="form-hint">Picking a preset will replace this rule.</span>
</p>
</div>
<!-- Location -->
<div class="form-field">
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
@@ -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);
+48 -2
View File
@@ -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
+9 -1
View File
@@ -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)
+16 -5
View File
@@ -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())