diff --git a/backend/main.py b/backend/main.py index a502c92..0cf2e85 100644 --- a/backend/main.py +++ b/backend/main.py @@ -69,6 +69,7 @@ from .schemas import ( UserOut, UserUpdate, ) +from .today_environment import get_environment as get_today_environment @asynccontextmanager @@ -195,6 +196,11 @@ async def me(user: User = Depends(current_user)): return user +@app.get("/api/v1/today/environment") +async def today_environment(_: User = Depends(current_user)): + return await get_today_environment() + + @app.patch("/api/v1/me", response_model=UserOut) async def update_me( payload: UserUpdate, diff --git a/backend/today_environment.py b/backend/today_environment.py new file mode 100644 index 0000000..5a0ad3a --- /dev/null +++ b/backend/today_environment.py @@ -0,0 +1,316 @@ +import asyncio +import logging +import math +import re +import threading +import weakref +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal, InvalidOperation +from html import unescape +from typing import Any +from zoneinfo import ZoneInfo + +import httpx + +from .lunar_support import solar_to_lunar_text + +SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") +WEATHER_URL = "https://api.open-meteo.com/v1/forecast" +GOLD_URL = "https://www.sge.com.cn/sjzx/yshqbg" +WEATHER_SOURCE = "Open-Meteo" +GOLD_SOURCE = "上海黄金交易所" +WEEKDAYS = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日") +SOURCE_TIMEOUT_SECONDS = 3.0 +TOTAL_TIMEOUT_SECONDS = 3.5 +WEATHER_TTL = timedelta(minutes=15) +WEATHER_STALE_TTL = timedelta(hours=6) +GOLD_TTL = timedelta(minutes=5) +GOLD_STALE_TTL = timedelta(days=7) + +logger = logging.getLogger(__name__) + +_TAG_RE = re.compile(r"<[^>]+>") +_DATE_RE = re.compile(r"上海黄金交易所\s*(\d{4})年(\d{2})月(\d{2})日\s*延时行情") +_ROW_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +_CELL_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) + + +@dataclass +class CacheEntry: + value: dict[str, Any] + fetched_at: datetime + + +@dataclass +class LoopRuntime: + inflight: dict[tuple[int, str], asyncio.Task[dict[str, Any]]] + + +_cache: dict[str, CacheEntry] = {} +_generation = 0 +_state_guard = threading.Lock() +_runtimes: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, LoopRuntime] = ( + weakref.WeakKeyDictionary() +) + + +def _runtime(loop: asyncio.AbstractEventLoop | None = None) -> LoopRuntime: + active_loop = loop or asyncio.get_running_loop() + with _state_guard: + runtime = _runtimes.get(active_loop) + if runtime is None: + runtime = LoopRuntime(inflight={}) + _runtimes[active_loop] = runtime + return runtime + + +def clear_cache() -> None: + global _generation + with _state_guard: + _generation += 1 + _cache.clear() + + +def _text(fragment: str) -> str: + return " ".join(unescape(_TAG_RE.sub(" ", fragment)).split()) + + +def parse_sge_au9999(html: str) -> tuple[Decimal, str]: + date_match = _DATE_RE.search(_text(html)) + if date_match is None: + raise ValueError("SGE market date not found") + try: + year, month, day = map(int, date_match.groups()) + market_date = date(year, month, day).isoformat() + except ValueError as exc: + raise ValueError("invalid SGE market date") from exc + + rows = [[_text(cell) for cell in _CELL_RE.findall(row)] for row in _ROW_RE.findall(html)] + header = next((cells for cells in rows if "合约" in cells and "最新价" in cells), None) + if header is None: + raise ValueError("SGE quote table header not found") + contract_index = header.index("合约") + price_index = header.index("最新价") + required_length = max(contract_index, price_index) + 1 + for cells in rows[rows.index(header) + 1 :]: + if len(cells) < required_length or cells[contract_index] != "Au99.99": + continue + try: + price = Decimal(cells[price_index].replace(",", "")) + except InvalidOperation as exc: + raise ValueError("invalid Au99.99 latest price") from exc + if not price.is_finite() or price <= 0: + raise ValueError("invalid Au99.99 latest price") + return price, market_date + raise ValueError("Au99.99 quote not found") + + +async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> dict[str, Any]: + payload = await request( + WEATHER_URL, + params={ + "latitude": 29.88, + "longitude": 121.55, + "current": "temperature_2m,apparent_temperature,weather_code", + "timezone": "Asia/Shanghai", + }, + ) + if payload.get("timezone") != "Asia/Shanghai": + raise ValueError("weather timezone must be Asia/Shanghai") + current = payload["current"] + if not isinstance(current, dict): + raise TypeError("weather current must be an object") + + def finite_number(name: str, minimum: float, maximum: float) -> int | float: + value = current[name] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"weather {name} must be numeric") + if not math.isfinite(value) or not minimum <= value <= maximum: + raise ValueError(f"weather {name} out of range") + return value + + temperature = finite_number("temperature_2m", -100, 100) + apparent_temperature = finite_number("apparent_temperature", -100, 100) + weather_code = current["weather_code"] + if isinstance(weather_code, bool) or not isinstance(weather_code, int): + raise TypeError("weather weather_code must be an integer") + if not 0 <= weather_code <= 99: + raise ValueError("weather weather_code out of range") + raw_time = current["time"] + if not isinstance(raw_time, str): + raise TypeError("weather time must be a string") + observed_at = datetime.fromisoformat(raw_time) + if observed_at.tzinfo is not None: + if observed_at.utcoffset() != SHANGHAI_TZ.utcoffset(observed_at): + raise ValueError("weather time must use Asia/Shanghai") + observed_at = observed_at.astimezone(SHANGHAI_TZ) + else: + observed_at = observed_at.replace(tzinfo=SHANGHAI_TZ) + return { + "temperature_c": temperature, + "apparent_temperature_c": apparent_temperature, + "weather_code": weather_code, + "observed_at": observed_at.isoformat(), + "source": WEATHER_SOURCE, + } + + +async def fetch_gold(request: Callable[..., Awaitable[str]]) -> dict[str, Any]: + price, market_date = parse_sge_au9999(await request(GOLD_URL)) + return { + "contract": "Au99.99", + "latest_price": price, + "currency": "CNY", + "unit": "gram", + "market_date": market_date, + "delayed": True, + "source": GOLD_SOURCE, + } + + +async def _request_json(client: httpx.AsyncClient, url: str, **kwargs: Any) -> dict[str, Any]: + response = await client.get(url, **kwargs) + response.raise_for_status() + return response.json() + + +async def _request_text(client: httpx.AsyncClient, url: str, **kwargs: Any) -> str: + response = await client.get(url, **kwargs) + response.raise_for_status() + return response.text + + +def _serializable(value: dict[str, Any]) -> dict[str, Any]: + return {key: str(item) if isinstance(item, Decimal) else item for key, item in value.items()} + + +async def _refresh_source( + name: str, + fetcher: Callable[[], Awaitable[dict[str, Any]]], + fetched_at: datetime, + generation: int, +) -> dict[str, Any]: + value = await asyncio.wait_for(fetcher(), timeout=SOURCE_TIMEOUT_SECONDS) + with _state_guard: + if generation == _generation: + newest = _cache.get(name) + if newest is None or newest.fetched_at <= fetched_at: + _cache[name] = CacheEntry(value=value, fetched_at=fetched_at) + return value + + +def _source_error_code(exc: BaseException) -> str: + if isinstance(exc, TimeoutError): + return "timeout" + if isinstance(exc, (KeyError, TypeError, ValueError, InvalidOperation)): + return "invalid_upstream_response" + return "upstream_unavailable" + + +def _finish_inflight( + runtime: LoopRuntime, + key: tuple[int, str], + task: asyncio.Task[dict[str, Any]], +) -> None: + # Calling exception() marks failures as retrieved even when every shielded waiter + # was cancelled. This synchronous callback cannot create an untracked cleanup task. + if not task.cancelled(): + try: + task.exception() + except asyncio.CancelledError: + pass + if runtime.inflight.get(key) is task: + runtime.inflight.pop(key, None) + + +async def _cached_source( + name: str, + fetcher: Callable[[], Awaitable[dict[str, Any]]], + now: datetime, + fresh_for: timedelta, + stale_for: timedelta, +) -> tuple[dict[str, Any] | None, str | None]: + runtime = _runtime() + with _state_guard: + generation = _generation + key = (generation, name) + with _state_guard: + cached = _cache.get(name) if generation == _generation else None + if cached is not None and now - cached.fetched_at <= fresh_for: + return {**_serializable(cached.value), "stale": False}, None + task = runtime.inflight.get(key) + if task is None: + task = asyncio.create_task(_refresh_source(name, fetcher, now, generation)) + runtime.inflight[key] = task + task.add_done_callback( + lambda completed, active=runtime, task_key=key: _finish_inflight( + active, task_key, completed + ) + ) + + try: + value = await asyncio.shield(task) + except Exception as exc: + error_code = _source_error_code(exc) + logger.warning("Today environment source %s failed (%s)", name, error_code, exc_info=exc) + with _state_guard: + generation_is_current = generation == _generation + if generation_is_current and cached is not None and now - cached.fetched_at <= stale_for: + return {**_serializable(cached.value), "stale": True}, error_code + return None, error_code + return {**_serializable(value), "stale": False}, None + + +async def _default_weather_fetcher() -> dict[str, Any]: + async with httpx.AsyncClient( + timeout=httpx.Timeout(SOURCE_TIMEOUT_SECONDS), + headers={"User-Agent": "dodo/0.1 (+https://dodo.bboy.app)"}, + ) as client: + return await fetch_weather(lambda url, **kwargs: _request_json(client, url, **kwargs)) + + +async def _default_gold_fetcher() -> dict[str, Any]: + async with httpx.AsyncClient( + timeout=httpx.Timeout(SOURCE_TIMEOUT_SECONDS), + headers={"User-Agent": "dodo/0.1 (+https://dodo.bboy.app)"}, + ) as client: + return await fetch_gold(lambda url, **kwargs: _request_text(client, url, **kwargs)) + + +async def get_environment( + *, + now: datetime | None = None, + weather_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None, + gold_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None, +) -> dict[str, Any]: + current = now or datetime.now(UTC) + local_date = current.astimezone(SHANGHAI_TZ).date() + weather_task = _cached_source( + "weather", weather_fetcher or _default_weather_fetcher, current, WEATHER_TTL, WEATHER_STALE_TTL + ) + gold_task = _cached_source( + "gold", gold_fetcher or _default_gold_fetcher, current, GOLD_TTL, GOLD_STALE_TTL + ) + weather_result, gold_result = await asyncio.wait_for( + asyncio.gather(weather_task, gold_task), timeout=TOTAL_TIMEOUT_SECONDS + ) + weather, weather_error = weather_result + gold, gold_error = gold_result + errors = {} + if weather_error: + errors["weather"] = weather_error + if gold_error: + errors["gold"] = gold_error + return { + "date": { + "solar_date": local_date.isoformat(), + "weekday": WEEKDAYS[local_date.weekday()], + "lunar": solar_to_lunar_text(local_date), + "timezone": "Asia/Shanghai", + }, + "weather": weather, + "gold": gold, + "errors": errors, + } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 20133f0..6d37d27 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,7 +21,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue' import CalendarPicker from './components/CalendarPicker.vue' import TaskDueDisplay from './components/TaskDueDisplay.vue' -import { useTaskDueClock } from './lib/task-due-clock' +import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue' +import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock' import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse' type FolderItem = { id: string; name: string } @@ -108,6 +109,10 @@ const todayTaskTotal = ref(0) const todayTaskCompleted = ref(0) const todayHabitTotal = ref(0) const todayHabitCompleted = ref(0) +const todayEnvironment = ref(null) +const todayEnvironmentLoading = ref(false) +const todayEnvironmentError = ref(false) +const todayEnvironmentDateKey = ref('') const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1) const expandedFolders = ref(new Set()) @@ -157,6 +162,7 @@ const selectedRepeatConfig = ref(defaultRepeatConfig()) const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }] let recurrenceLoadToken = 0 let todaySummaryLoadToken = 0 +let todayEnvironmentLoadToken = 0 const habitComposer = ref | null>(null) const countdownComposer = ref | null>(null) const memoPanel = ref | null>(null) @@ -534,6 +540,27 @@ async function loadTodayTaskSummary() { todayTaskTotal.value = overdue + open + completed } catch { /* 概览统计失败不阻断今天页 */ } } +async function loadTodayEnvironment() { + const token = ++todayEnvironmentLoadToken + todayEnvironmentLoading.value = true + todayEnvironmentError.value = false + try { + const data = await api('/today/environment') as TodayEnvironment + if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return + todayEnvironment.value = data + todayEnvironmentDateKey.value = data.date?.solar_date || shanghaiDateKey() + } catch { + if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return + todayEnvironmentError.value = true + } finally { + if (token === todayEnvironmentLoadToken && activeView.value === 'today') todayEnvironmentLoading.value = false + } +} +function handleTodayEnvironmentResume() { + if (document.visibilityState === 'hidden' || activeView.value !== 'today') return + if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment() +} +watchShanghaiDateRollover(taskDueNowMs, handleTodayEnvironmentResume) async function loadOverdueTasks(request = beginLatestRequest('tasks')) { const params = new URLSearchParams() params.set('due_to', isoAtLocalDayOffset(0)) @@ -601,6 +628,7 @@ async function loadAll() { if (!navigationLoaded.value) await loadNavigation() if (!isLatestRequest('tasks', request)) return if (activeView.value === 'today') { + void loadTodayEnvironment() await startPrimaryWithBackground( [() => loadTasksPage(request), () => loadOverdueTasks(request)], loadTodayTaskSummary, @@ -655,6 +683,10 @@ async function switchView(view: View, listId?: string) { taskMutationNavigation.value += 1 taskReorderMode.value = false cancelTaskReorder() + if (view !== 'today') { + ++todayEnvironmentLoadToken + todayEnvironmentLoading.value = false + } activeView.value = view if (!query.value) mobileSearchOpen.value = false searchPullDistance.value = 0 @@ -1376,6 +1408,9 @@ onMounted(() => { document.addEventListener('keydown', handleArchivedListEscape) document.addEventListener('keydown', handleTaskSearchShortcut) window.addEventListener('resize', handleViewportResize) + document.addEventListener('visibilitychange', handleTodayEnvironmentResume) + window.addEventListener('focus', handleTodayEnvironmentResume) + window.addEventListener('pageshow', handleTodayEnvironmentResume) document.addEventListener('scroll', handleArchivedListViewportChange, true) void bootstrap() }) @@ -1384,6 +1419,9 @@ onUnmounted(() => { document.removeEventListener('keydown', handleArchivedListEscape) document.removeEventListener('keydown', handleTaskSearchShortcut) window.removeEventListener('resize', handleViewportResize) + document.removeEventListener('visibilitychange', handleTodayEnvironmentResume) + window.removeEventListener('focus', handleTodayEnvironmentResume) + window.removeEventListener('pageshow', handleTodayEnvironmentResume) document.removeEventListener('scroll', handleArchivedListViewportChange, true) }) @@ -1491,6 +1529,7 @@ onUnmounted(() => {