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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user