Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8765959ea | |||
| c33cab7020 | |||
| 36cd08c236 |
@@ -545,7 +545,7 @@ export interface EventUpdatePayload {
|
|||||||
description?: string;
|
description?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
recurrence?: string;
|
recurrence?: string | null;
|
||||||
project_id?: number;
|
project_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,36 @@ const description = ref("");
|
|||||||
const location = ref("");
|
const location = ref("");
|
||||||
const color = ref("");
|
const color = ref("");
|
||||||
const projectId = ref<number | null>(null);
|
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 {
|
function dateFromIso(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -115,6 +145,7 @@ function resetForm() {
|
|||||||
location.value = props.event.location || "";
|
location.value = props.event.location || "";
|
||||||
color.value = props.event.color || "";
|
color.value = props.event.color || "";
|
||||||
projectId.value = props.event.project_id;
|
projectId.value = props.event.project_id;
|
||||||
|
recurrence.value = props.event.recurrence || "";
|
||||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||||
} else {
|
} else {
|
||||||
@@ -130,6 +161,7 @@ function resetForm() {
|
|||||||
location.value = "";
|
location.value = "";
|
||||||
color.value = "";
|
color.value = "";
|
||||||
projectId.value = null;
|
projectId.value = null;
|
||||||
|
recurrence.value = "";
|
||||||
_lastDurationMin = 60;
|
_lastDurationMin = 60;
|
||||||
}
|
}
|
||||||
deleteConfirm.value = false;
|
deleteConfirm.value = false;
|
||||||
@@ -257,6 +289,7 @@ async function save() {
|
|||||||
location: location.value,
|
location: location.value,
|
||||||
color: color.value,
|
color: color.value,
|
||||||
project_id: projectId.value ?? undefined,
|
project_id: projectId.value ?? undefined,
|
||||||
|
recurrence: recurrence.value || null,
|
||||||
};
|
};
|
||||||
const updated = await updateEvent(props.event.id, payload);
|
const updated = await updateEvent(props.event.id, payload);
|
||||||
emit("updated", updated);
|
emit("updated", updated);
|
||||||
@@ -270,6 +303,7 @@ async function save() {
|
|||||||
location: location.value,
|
location: location.value,
|
||||||
color: color.value,
|
color: color.value,
|
||||||
project_id: projectId.value ?? undefined,
|
project_id: projectId.value ?? undefined,
|
||||||
|
recurrence: recurrence.value || undefined,
|
||||||
};
|
};
|
||||||
const created = await createEvent(payload);
|
const created = await createEvent(payload);
|
||||||
emit("created", created);
|
emit("created", created);
|
||||||
@@ -374,6 +408,24 @@ async function doDelete() {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Location -->
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||||
@@ -539,6 +591,22 @@ async function doDelete() {
|
|||||||
color: var(--color-text-secondary);
|
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 {
|
.toggle-btn {
|
||||||
background: var(--color-input-bg, #111113);
|
background: var(--color-input-bg, #111113);
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ async def _resolve_config(user_id: int) -> dict:
|
|||||||
return {**DEFAULT_JOURNAL_CONFIG, **config}
|
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")
|
@journal_bp.get("/config")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_config():
|
async def get_config():
|
||||||
@@ -82,9 +93,41 @@ async def put_config():
|
|||||||
return jsonify({"error": "config must be an object"}), 400
|
return jsonify({"error": "config must be an object"}), 400
|
||||||
await set_setting(user_id, "journal_config", json.dumps(body))
|
await set_setting(user_id, "journal_config", json.dumps(body))
|
||||||
await update_user_schedule(user_id)
|
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})
|
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")
|
@journal_bp.get("/today")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_today():
|
async def get_today():
|
||||||
@@ -258,7 +301,9 @@ async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> No
|
|||||||
@login_required
|
@login_required
|
||||||
async def get_weather():
|
async def get_weather():
|
||||||
user_id = get_current_user_id()
|
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)
|
temp_unit = await _journal_temp_unit(user_id)
|
||||||
|
|
||||||
# Kick off a best-effort background refresh for stale rows so the next page
|
# Kick off a best-effort background refresh for stale rows so the next page
|
||||||
@@ -318,7 +363,8 @@ async def refresh_weather():
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
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 = [
|
cards = [
|
||||||
card for row in rows
|
card for row in rows
|
||||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
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"] = []
|
sections["events"] = []
|
||||||
|
|
||||||
try:
|
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]
|
sections["weather"] = [w.to_dict() for w in weather_rows]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("daily_prep weather section failed for user %d", user_id)
|
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:
|
async def get_cached_weather_rows(
|
||||||
"""Return raw WeatherCache ORM rows for a user (for card parsing)."""
|
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:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
stmt = select(WeatherCache).where(WeatherCache.user_id == user_id)
|
||||||
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())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user