diff --git a/.trellis/spec/web/index.md b/.trellis/spec/web/index.md index a746ac3..150b6eb 100644 --- a/.trellis/spec/web/index.md +++ b/.trellis/spec/web/index.md @@ -48,10 +48,61 @@ Login and public marketing/legal pages are not part of the authenticated app she - Shared request behavior lives in `web/src/lib/api-client.ts`. - Auth/session behavior lives in `web/src/lib/auth.ts`. - Business API functions live in `web/src/lib/api.ts`. +- Shared authenticated read caching lives in `web/src/lib/data-client.ts` and `web/src/lib/resources.ts`. - Components must call typed API helper functions, not inline `fetch('/api/...')`. +- Components that need profile, points, packages, history, notifications, or unread-count data should use the resource hooks/functions from `web/src/lib/resources.ts` instead of starting their own duplicate GET lifecycle. - Dashboard-visible user, points, notification, and history data must come from the backend. Do not hardcode those values. - Production API host is `https://api.meeyao.com`; local dev should use same-origin `/api` and the Vite proxy. +### Authenticated Data Resource Pattern + +Use this pattern for backend reads that are reused across authenticated pages: + +```typescript +// lib/api.ts: transport-only business API +export function getPointsBalance(): Promise { + return authFetch(API_ROUTES.points.balance); +} + +// lib/resources.ts: cache policy + hook/function surface +export function usePoints() { + return useResource({ + key: pointsBalanceKey, + ttlMs: 60_000, + fetcher: getPointsBalance, + staleWhileRevalidate: true, + }); +} +``` + +Resource contracts: + +- `lib/api.ts` remains transport-only: no per-endpoint ad hoc memory cache there. +- `lib/resources.ts` owns resource keys, TTLs, in-flight dedupe, stale-while-revalidate behavior, prefetch, and mutation invalidation. +- `clearAuth()` must clear the shared data cache so authenticated data cannot leak across users. +- Resource hooks must support disabled/optional keys for pages where an id may be absent; do not create a fetcher that intentionally rejects during normal render. +- Active hooks must refetch after invalidation when they still need the resource. + +Invalidation matrix: + +- Profile, avatar, or settings write -> set the profile resource with the returned backend profile. +- Divination run or follow-up completion -> invalidate points and the relevant history list/thread resources. +- Notification mark-read -> patch the notification list and decrement unread count when the item changes from unread to read. +- Mark-all-notifications-read -> patch the notification list and set unread count to zero. +- Logout, expired refresh, or invalid auth -> clear auth and clear all resource data. + +Wrong vs correct: + +```typescript +// Wrong: every page starts an independent duplicate GET. +useEffect(() => { + getUserProfile().then(setProfile); +}, []); + +// Correct: subscribe to the shared profile resource. +const profileState = useProfile(); +``` + ## Layout Rules - Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/check.jsonl b/.trellis/tasks/05-10-audit-and-optimize-web-performance/check.jsonl new file mode 100644 index 0000000..e2a57e3 --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Verify request behavior, authenticated routing, and responsive browser checks."} diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/implement.jsonl b/.trellis/tasks/05-10-audit-and-optimize-web-performance/implement.jsonl new file mode 100644 index 0000000..b9d509e --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app architecture, API, auth, and performance-sensitive layout rules."} diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md new file mode 100644 index 0000000..1a2f78d --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md @@ -0,0 +1,82 @@ +# Audit and optimize web performance + +## Goal + +Comprehensively audit and refactor the web frontend data interaction layer while preserving the existing UI. The refactor should reduce repeated HTTP requests, hide US-backend latency where safe, centralize cache/invalidation behavior, and make authenticated page transitions feel faster in both perceived and actual request-count terms. + +## What I already know + +* Backend is hosted in the US, so users far away experience noticeable request latency. +* User has observed repeated HTTP requests and avoidable data-loading waits. +* Web stack is Astro 6 + React 19 + React Router DOM under `web/`. +* Auth state lives in `web/src/lib/auth.ts`; business APIs live in `web/src/lib/api.ts`. +* Existing `getPointsBalance()` has a 1-minute in-memory TTL cache, but many other stable reads do not. +* Early code scan found repeated `getUserProfile()` calls across `AppShell`, `LoginForm`, `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage`. +* `AppShell` already loads profile globally and exposes `UserSettingsContext`; some pages still refetch profile instead of using that context. +* Auth refresh now has single-flight behavior from the previous fix, which helps avoid concurrent refresh waste. + +## Assumptions + +* Performance improvements should preserve backend source of truth and auth safety. +* Cached authenticated data must be invalidated after writes that change it. +* We should prefer a local, typed data-client/resource layer over adding a large state/query dependency unless audit shows the custom approach is too risky. +* Optimizations should be observable through request count reduction and improved perceived loading behavior. + +## Requirements + +* Audit all authenticated web pages for duplicate or avoidable requests. +* Define and implement a frontend data layer with caching, in-flight dedupe, stale-while-revalidate, prefetch, and explicit invalidation. +* Reuse AppShell-loaded profile/settings where possible instead of refetching. +* Add cache invalidation for profile/settings/points changes. +* Keep UI presentation unchanged at rest; only data source and loading/refresh behavior should change. +* Avoid stale or unsafe auth behavior: 401 and refresh failures must still clear auth and redirect. +* Improve perceived performance with cached data, route-level reuse, and fewer full-page loading waits. +* Document verification with before/after request counts for key flows. + +## Acceptance Criteria + +* [x] Request audit lists endpoints, call sites, duplicate patterns, and priority. +* [x] Refactor plan defines target data architecture, resource cache policies, invalidation rules, and phased rollout. +* [x] Data client/resource layer is implemented without changing the visible UI design. +* [x] Dashboard → settings/profile/general/divination navigation avoids duplicate profile requests where safe. +* [x] Points balance requests are deduped in-flight and cached/invalidated intentionally. +* [x] Stable package/config-style reads are cached or intentionally left uncached with rationale. +* [x] Authenticated pages keep correct behavior after writes and after 401 responses. +* [ ] Browser verification captures representative flows under mobile and desktop viewports. +* [x] `git diff --check` passes; build/typecheck status documented. + +## Definition of Done + +* PRD updated with final implementation notes. +* Targeted code changes committed. +* Performance audit and refactor plan recorded in the task directory. +* Any reusable conventions captured in `.trellis/spec/web/index.md` or relevant spec. + +## Out of Scope + +* Backend endpoint redesign. +* CDN/edge deployment changes. +* Offline mode. +* Large dependency adoption unless explicitly approved after audit. +* Visual redesign of existing pages. + +## Technical Notes + +* Follow `.trellis/spec/web/index.md`. +* All API paths remain in `web/src/lib/api-routes.ts`. +* Components should call typed API helpers from `web/src/lib/api.ts`, not inline `fetch('/api/...')`. +* Target refactor plan: `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md`. +* Request audit: `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md`. +* Current build is blocked by existing Astro adapter configuration (`NoAdapterInstalled`) and should be documented unless fixed in a separate task. + +## Implementation Notes - 2026-05-10 + +* Added `web/src/lib/data-client.ts`, a typed in-memory query cache with TTL, in-flight request dedupe, `peek`, `set`, prefix `invalidate`, `prefetch`, `subscribe`, and `clearAll`. +* Added `web/src/lib/resources.ts` for profile, points, packages, history list/thread/summary, notifications list, and unread-count cache policy plus React resource hooks. +* Moved points TTL behavior out of `api.ts`; `getPointsBalance()` is now transport-only and points caching lives in the resource layer. +* AppShell now loads profile through the profile resource, subscribes to profile cache changes, clears data cache via `clearAuth()`, prefetches cheap dashboard data after auth, and prefetches route resources on nav hover/focus. +* Settings, general settings, profile detail, dashboard, store, history list, notifications, manual/auto divination, divination processing, result, and follow-up pages now reuse resource caches instead of owning duplicate GET lifecycles. +* Mutations patch or invalidate shared cache: profile/settings/avatar set profile; notification read/all-read patch list and unread count; divination/follow-up completion invalidates points and history. +* Quality check fixed two post-implementation issues: invalidated active resources now refetch instead of staying empty/stale, and `useHistoryThread(undefined)` no longer emits a doomed request when result pages rely on router/session state. +* Verification: `npm exec astro sync` passed; `git diff --check` passed; `npm run build` remains blocked by existing Astro `NoAdapterInstalled`; temporary `tsc --noEmit` check reports existing `DashboardApp.tsx` translation-map typing errors after refactor-specific type issues were fixed. +* Browser verification note: the in-app browser was switched to a visible 390x844 mobile viewport with the dev server on `127.0.0.1:4322`, but the automation surface only exposed the in-app browser toolbar during this run, so request-count capture still needs a follow-up pass with a usable page automation surface. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md new file mode 100644 index 0000000..10c1e0a --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md @@ -0,0 +1,383 @@ +# Web data interaction refactor plan + +## Executive summary + +The current web app has solid typed API helpers, but it does not yet have an application data layer. Most pages call backend endpoints directly inside local `useEffect` blocks, so route changes repeatedly reload the same user/profile/points/history data. This is especially visible when the backend is far away because every avoidable request becomes a user-visible wait. + +The recommended refactor is to keep the UI unchanged and introduce a lightweight, project-owned data layer that centralizes: + +* request in-flight dedupe +* TTL cache and stale-while-revalidate behavior +* explicit invalidation after writes +* route-level prefetch +* shared resource state for pages +* optimistic local updates where safe + +Do not start by sprinkling `localStorage` or ad hoc memoization into components. That would reduce some requests but preserve the deeper problem: every component still owns its own data lifecycle. + +## Current architecture diagnosis + +### 1. API helpers are typed, but not stateful + +`web/src/lib/api.ts` provides typed functions such as `getUserProfile`, `getPointsBalance`, `getAgentHistory`, and `getNotifications`. Except for points, those functions always hit the backend. + +This is clean as a transport layer, but incomplete as a frontend data layer. + +### 2. Components own network lifecycle + +Examples: + +* `AppShell` fetches profile globally, but `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage` refetch profile. +* `Dashboard` fetches points/history/unread count on mount. +* `HistoryListPage` fetches history again even if dashboard just fetched it. +* `StorePage` fetches packages on mount even though packages are stable config-style data. +* `NotificationPage` owns its own list and mark-read updates without updating dashboard unread-count cache. + +Each page is locally correct, but globally wasteful. + +### 3. Loading states are page-local + +Because data state is local to each page, the UI often transitions from a populated page to a blank/loading page even if relevant data was just fetched elsewhere. This makes high-latency backend access feel worse than it needs to. + +### 4. Mutations do not have a shared invalidation model + +Examples: + +* `updateUserSettings` returns updated profile, but only the calling component updates local state. +* `updateUserProfile` / `uploadAvatar` can leave `AppShell` profile stale unless context is manually updated. +* `markNotificationRead` updates the notification page list, but dashboard unread count is not centrally invalidated. +* `enqueueDivinationRun` should invalidate points and history after success, but that is not represented in a central policy. + +## Target architecture + +### Layer 1: transport stays simple + +Keep `authFetch`, `authFetchRaw`, `apiUrl`, `api-routes`, and RFC7807 parsing as the low-level transport boundary. + +Responsibilities: + +* auth refresh and 401 handling +* JSON request/response parsing +* SSE raw response support +* no UI cache knowledge + +### Layer 2: API functions remain typed commands + +Keep `web/src/lib/api.ts` as the typed backend API command layer. + +Responsibilities: + +* endpoint-specific payload construction +* response types +* backend data mapping helpers +* no React hooks + +Change: + +* Remove ad hoc points-only cache from this file. +* Move caching into a dedicated data/cache layer. + +### Layer 3: new app data client + +Add `web/src/lib/data-client.ts` or `web/src/lib/query-client.ts`. + +Core capabilities: + +```ts +type CacheKey = readonly string[]; + +interface CacheEntry { + data?: T; + error?: unknown; + updatedAt: number; + expiresAt: number; + promise?: Promise; +} + +interface QueryOptions { + key: CacheKey; + ttlMs: number; + fetcher: () => Promise; + staleWhileRevalidate?: boolean; +} +``` + +Required operations: + +* `query(options): Promise`: returns fresh data, dedupes in-flight request. +* `peek(key): T | undefined`: synchronous read for instant UI seeding. +* `set(key, data, ttlMs)`: update cache after mutations. +* `invalidate(keyPrefix)`: remove or mark stale. +* `prefetch(options)`: warm cache without forcing UI loading. +* `clearAll()`: call on logout or terminal auth failure. + +Why local helper first: + +* Project currently has no query library. +* The data model is small enough to solve with a typed helper. +* Avoid adding dependency and architecture churn before measuring gains. + +Future option: + +* If data flows grow, migrate the same key model to TanStack Query later. The refactor should name cache keys and invalidation policies in a way that would map cleanly. + +### Layer 4: resource-specific services + +Add resource wrappers, for example: + +* `web/src/lib/resources/profile.ts` +* `web/src/lib/resources/points.ts` +* `web/src/lib/resources/store.ts` +* `web/src/lib/resources/history.ts` +* `web/src/lib/resources/notifications.ts` + +Each resource owns: + +* cache key +* TTL +* fetch function +* invalidation after writes +* local patch helpers + +Example: + +```ts +export const profileKey = ['profile'] as const; + +export function getProfileResource() { + return query({ + key: profileKey, + ttlMs: 5 * 60_000, + fetcher: getUserProfile, + staleWhileRevalidate: true, + }); +} + +export async function updateProfileResource(input: UpdateProfileRequest) { + const updated = await updateUserProfile(input); + set(profileKey, updated, PROFILE_TTL); + return updated; +} +``` + +### Layer 5: React provider and hooks + +Add `AppDataProvider` under the authenticated app shell. + +Hooks: + +* `useResource(key, loader, options)` +* `useProfile()` +* `usePoints()` +* `useHistorySummary()` +* `useHistoryThread(threadId)` +* `useNotifications(locale)` +* `useUnreadCount()` + +Hook behavior: + +* seed UI from `peek()` +* return cached data immediately when available +* refresh in background when stale +* expose `{ data, loading, refreshing, error, reload }` +* never bypass auth 401 behavior + +This keeps UI unchanged: pages still render the same cards/lists/forms, but data source shifts from local `useEffect` to shared hooks. + +## Cache policy by data domain + +| Domain | Key | TTL | Stale UI allowed | Invalidate on | +| --- | --- | ---: | --- | --- | +| Auth session | localStorage `meeyao_auth` | token expiry | no | logout, 401 refresh failure | +| Profile/settings | `['profile']` | 5 min | yes | profile update, settings update, avatar upload, logout | +| Points balance | `['points', 'balance']` | 30-60 sec | yes | divination run accepted/success, purchase, manual reload, logout | +| Packages | `['points', 'packages']` | 30 min | yes | app reload, explicit admin/package changes if added later | +| Dashboard history summary | `['history', 'summary']` | 60 sec | yes | divination run success, follow-up success, session delete | +| History full list | `['history', 'list']` | 60 sec | yes | divination run success, follow-up success, session delete | +| History thread | `['history', 'thread', threadId]` | 2-5 min | yes | follow-up success for same thread, session delete | +| Notifications list | `['notifications', locale]` | 60 sec | yes | mark read/all read, new notification polling if added | +| Unread count | `['notifications', 'unread-count']` | 30 sec | yes | mark read/all read, notification list refresh | + +## Display-state model + +Every page should distinguish: + +* `loading`: no cached data yet and initial request is pending +* `refreshing`: cached data is visible while background request updates it +* `error`: no usable data, or background refresh failed +* `stale`: data is visible but may be older than TTL + +UI stays visually the same, but behavior improves: + +* If cached data exists, do not show full-page loading. +* Use existing skeleton/spinner only for first load. +* On refresh failure, keep visible stale data and show a small non-blocking error only where useful. + +## Route prefetch strategy + +In `AppShell`, prefetch cheap likely-next data: + +* After auth/profile load: + * points balance + * unread notification count + * dashboard history summary +* On sidebar hover/focus/click intent: + * Store: packages + points + * History: history list + * Settings/Profile/General: profile + * Divination manual/auto: points + * Notifications: notification list + unread count + +Prefetch must be bounded: + +* only authenticated routes +* no SSE prefetch +* no mutation prefetch +* no repeated prefetch while in-flight or fresh + +## Mutation and invalidation policy + +### Profile/settings + +* `updateUserProfile` and `uploadAvatar` + * set `['profile']` to returned profile + * update `AppShell` user context immediately +* `updateUserSettings` + * set `['profile']` to returned profile + * redirect language only after cache/context update + +### Points/store + +* Purchase flow or future payment success: + * invalidate `['points', 'balance']` +* Divination run: + * optimistically mark points stale when run is accepted + * invalidate points and history after run finishes + +### History + +* Divination run success: + * set result in sessionStorage/router state as today + * invalidate history summary/list + * optionally seed `['history', 'thread', threadId]` if enough data exists +* Follow-up success: + * invalidate `['history', 'thread', threadId]` + * invalidate summary/list + +### Notifications + +* Mark one read: + * patch `['notifications', locale]` + * decrement `['notifications', 'unread-count']` locally +* Mark all read: + * patch list to all read + * set unread count to 0 + +## Implementation phases + +### Phase 1: measurement and guardrails + +Deliverables: + +* Add a dev-only request logger around `authFetch`/`apiRequest`. +* Capture before counts for: + * login -> dashboard + * dashboard -> settings -> profile -> general + * dashboard -> manual/auto divination + * dashboard -> history + * dashboard -> store +* Keep UI unchanged. + +Acceptance: + +* We can prove before/after request count improvements. + +### Phase 2: data client foundation + +Deliverables: + +* Add typed cache/data client. +* Move existing points TTL cache into the data client. +* Add clear-on-logout/auth-failure hook. +* Add unit-light self checks where possible, or targeted browser checks if test infra is absent. + +Acceptance: + +* In-flight duplicate calls collapse into one request. +* Cache invalidation is explicit and inspectable. + +### Phase 3: profile/settings context refactor + +Deliverables: + +* AppShell uses profile resource. +* SettingsPage/ProfileDetailPage/GeneralSettingsPage use profile resource/context instead of unconditional fetch. +* Mutations update profile cache and shell state. + +Acceptance: + +* Dashboard -> settings -> profile -> general should not issue repeated `GET /users/me/profile` while profile is fresh. + +### Phase 4: points, packages, dashboard + +Deliverables: + +* Points uses data client TTL + in-flight dedupe. +* Packages cached with long TTL. +* Dashboard data uses shared resources. +* Store/divination/settings reuse points cache. + +Acceptance: + +* Dashboard -> store -> divination does not repeatedly fetch points/packages while fresh. + +### Phase 5: history and notifications + +Deliverables: + +* History list/summary/thread resources. +* Dashboard and HistoryListPage share cached history. +* Notification list/unread resources with local patch after mark-read. + +Acceptance: + +* Dashboard -> history reuses fresh history data. +* Mark-read updates unread count without waiting for dashboard reload. + +### Phase 6: perceived-performance polish + +Deliverables: + +* Replace full-page loading on cached routes with stale data + subtle refreshing state. +* Add bounded route prefetch in AppShell. +* Add regression browser checks for mobile/desktop. + +Acceptance: + +* UI remains visually unchanged at rest. +* Route transitions feel instant when data is cached. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| Stale user-visible data | Short TTL for volatile data; explicit invalidation after writes. | +| Auth cache leaking between users | Clear all data cache on logout, missing auth, refresh failure, and user id change. | +| Hidden background refresh errors | Keep stale data visible but expose reload/error state for first-load failures. | +| Over-prefetch increasing traffic | Prefetch only small GET endpoints and rely on in-flight/fresh checks. | +| Refactor touching too many pages at once | Ship by phases with request-count verification after each phase. | +| UI drift | Do not alter markup/layout except replacing data source/loading conditions. | + +## Recommended final architecture + +``` +React Page + -> useProfile/usePoints/useHistory/useNotifications + -> Resource wrapper (keys, TTL, invalidation) + -> DataClient (cache, in-flight dedupe, stale-while-revalidate) + -> typed API functions in api.ts + -> authFetch/apiRequest transport + -> backend +``` + +The core principle: components describe what data they need; resource wrappers decide when the backend actually needs to be called. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md new file mode 100644 index 0000000..5567cf3 --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md @@ -0,0 +1,60 @@ +# Web request audit + +## Initial request topology + +### App shell and auth + +| Area | Request(s) | Current behavior | Waste / risk | +| --- | --- | --- | --- | +| `AppShell` | `refreshAccessToken()`, `getUserProfile()` | Runs on authenticated shell mount. Profile is stored in `UserSettingsContext`. | This should be the primary profile/settings source for child pages. | +| `LoginForm` existing auth check | `refreshAccessToken()`, `getUserProfile()` | Valid existing session fetches profile to choose locale before redirect. | Acceptable, but could reuse stored auth language if persisted in future. | +| `LoginForm` submit | `loginWithEmail()`, `getUserProfile()` | Login response lacks settings, so profile is fetched to choose locale. | Acceptable until backend returns language/settings in auth response. | + +### Duplicate profile/settings reads + +| Page | Current request | Better behavior | +| --- | --- | --- | +| `SettingsPage` | `getUserProfile()` + `getPointsBalance()` | Reuse `UserSettingsContext.userProfile`; fetch only points. | +| `GeneralSettingsPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserSettings()`. | +| `ProfileDetailPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserProfile()` / avatar upload. | +| `ManualDivinationPage` / `AutoDivinationPage` | import `getUserProfile` but rely on context for profile; `getUserProfile` import appears unused | Remove unused import and keep using context. | + +### Points and store reads + +| Page | Current request | Better behavior | +| --- | --- | --- | +| `Dashboard` | `getPointsBalance()`, `getUnreadNotificationCount()`, `getAgentHistory()` | Points has TTL cache, but should dedupe in-flight. History can be shared with history list via short TTL cache. | +| `SettingsPage` | `getPointsBalance()` | Keep cache, add in-flight dedupe and stale-while-revalidate option. | +| `StorePage` | `getPointsBalance()`, `getPackages()` | Cache packages for a longer TTL because packages are stable configuration. Keep explicit invalidation after purchase/payment flows. | +| `ManualDivinationPage` / `AutoDivinationPage` | `getPointsBalance()` | Reuse points cache/in-flight dedupe; consider prefetch when entering divination nav group. | + +### History and notifications + +| Area | Current request | Better behavior | +| --- | --- | --- | +| `Dashboard` | `getAgentHistory()` for latest four | Add short TTL/in-flight cache for history summary; history list can reuse if still fresh. | +| `HistoryListPage` | `getAgentHistory()` full list | Reuse cache populated by dashboard, then refresh in background. | +| `NotificationPage` | `getNotifications(locale)` | Cache list briefly per locale. Invalidate/update locally after mark-read actions. | +| Dashboard unread badge | `getUnreadNotificationCount()` | Cache briefly; invalidate/update after mark-read or mark-all-read. | + +## Priority plan + +1. Add a small typed cache helper in `web/src/lib/api.ts` or adjacent `web/src/lib/api-cache.ts` for: + - TTL cache + - in-flight promise dedupe + - explicit invalidation + - optional stale return with background refresh where UI can support it +2. Apply first to profile/settings, points, packages, history summary/list, notification list/count. +3. Refactor pages to use `UserSettingsContext` for profile reads. +4. Add prefetch hooks at AppShell/nav boundaries where it is cheap and safe. +5. Browser-verify request count reductions on: + - login -> dashboard + - dashboard -> settings -> profile -> general + - dashboard -> manual/auto divination + - dashboard -> history + - dashboard -> store + +## Open implementation questions + +* Whether to implement a minimal local cache helper or introduce a query library. Current repo has no query dependency, so default recommendation is a minimal helper first. +* Whether auth login response should eventually include profile language/settings to avoid immediate post-login profile fetch. This would be a backend contract change and is out of scope for the first frontend-only optimization pass. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/task.json b/.trellis/tasks/05-10-audit-and-optimize-web-performance/task.json new file mode 100644 index 0000000..3e6bf2e --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/task.json @@ -0,0 +1,26 @@ +{ + "id": "audit-and-optimize-web-performance", + "name": "audit-and-optimize-web-performance", + "title": "Audit and optimize web performance", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-10", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 8f86306..4fe0d91 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -2,7 +2,9 @@ import { useState, useEffect, createContext, useContext, type ReactNode } from ' import { useLocation, useNavigate } from 'react-router-dom'; import Icon from './Icon'; import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth'; -import { getUserProfile, type UserProfile } from '../lib/api'; +import type { UserProfile } from '../lib/api'; +import { getCachedProfile, getProfileResource, prefetchAppBasics, prefetchForPath, profileKey } from '../lib/resources'; +import { subscribe } from '../lib/data-client'; // User settings context interface UserSettingsContextValue { @@ -80,11 +82,12 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm refreshAccessToken() .then((data) => { if (alive) setAuthUser(data.user); - return getUserProfile(); + return getProfileResource(); }) .then((profile) => { if (!alive) return; setUserProfile(profile); + prefetchAppBasics(); // Check if URL locale matches user's language preference const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN'); @@ -107,6 +110,14 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm }; }, [locale]); + useEffect(() => { + const cached = getCachedProfile(); + if (cached) setUserProfile(cached); + return subscribe(profileKey, () => { + setUserProfile(getCachedProfile() ?? null); + }); + }, []); + useEffect(() => { if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination'); }, [activeNav]); @@ -116,6 +127,10 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm routerNavigate(href); }; + const prefetchNav = (href: string) => { + prefetchForPath(href, locale); + }; + if (checkingAuth || authUser === null) { return (
@@ -125,7 +140,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm } const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || ''; - const shellUserEmail = userEmail || userProfile?.email || authUser?.email || ''; + const shellUserEmail = userEmail || authUser?.email || ''; const shellAvatarUrl = userProfile?.avatar_url; return ( @@ -169,6 +184,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm {!sidebarCollapsed && isExpanded && item.sub.map((sub) => ( prefetchNav(sub.href)} + onFocus={() => prefetchNav(sub.href)} onClick={(e) => { e.preventDefault(); navigate(sub.href); setSidebarOpen(false); }} className={`flex items-center gap-2 pl-8 pr-2.5 py-2.5 rounded-md text-sm transition-colors border ${activeNav === sub.id ? 'bg-[#F0E6FF] border-violet-600 text-violet-700 font-bold' : 'border-transparent text-slate-500 hover:bg-slate-50'}`}> {activeNav === sub.id ? '●' : '○'}{sub.label} @@ -183,6 +200,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm if (item.href) { return ( prefetchNav(item.href)} + onFocus={() => prefetchNav(item.href)} onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }} title={sidebarCollapsed ? item.label : undefined} className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}> diff --git a/web/src/components/AutoDivinationPage.tsx b/web/src/components/AutoDivinationPage.tsx index 498451f..4e36907 100644 --- a/web/src/components/AutoDivinationPage.tsx +++ b/web/src/components/AutoDivinationPage.tsx @@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import Icon from './Icon'; import DivinationProcessingOverlay from './DivinationProcessingOverlay'; -import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData, type YaoType } from '../lib/api'; +import { type DivinationResultData, type YaoType } from '../lib/api'; +import { updateSettingsResource, usePoints } from '../lib/resources'; import { useUserSettings } from './AppShell'; interface Props { @@ -180,12 +181,13 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { const text = copy[locale as keyof typeof copy] ?? copy.zh; const cats = useMemo(() => d.categories.split(','), [d.categories]); const navigate = useNavigate(); - const [category, setCategory] = useState(cats[0]); - const [question, setQuestion] = useState(text.defaultQuestion); + const [category, setCategory] = useState(cats[0]); + const [question, setQuestion] = useState(text.defaultQuestion); const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date())); const [yaoResults, setYaoResults] = useState([]); const [guideStep, setGuideStep] = useState(null); - const [points, setPoints] = useState(null); + const pointsState = usePoints(); + const points = pointsState.data ?? null; const [showProcessing, setShowProcessing] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const { userProfile, setUserProfile } = useUserSettings(); @@ -237,7 +239,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { }, }; try { - const updated = await updateUserSettings({ settings: updatedSettings }); + const updated = await updateSettingsResource(updatedSettings); setUserProfile(updated); } catch { // Silently fail @@ -364,10 +366,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { setCategory(cats[0]); }, [cats]); - useEffect(() => { - getPointsBalance().then(setPoints).catch(() => {}); - }, []); - useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < 1280); window.addEventListener('resize', handleResize); diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 31929b2..bc570e0 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -1,7 +1,5 @@ -import { useState, useEffect } from 'react'; -import { getPointsBalance, type PointsBalance } from '../lib/api'; -import { getUnreadNotificationCount, type UnreadCount } from '../lib/api'; -import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api'; +import { mapHistoryMessagesToItems } from '../lib/api'; +import { useHistorySummary, usePoints, useUnreadCount } from '../lib/resources'; import Icon from './Icon'; interface DashboardProps { @@ -53,40 +51,15 @@ const RATING_COLORS: Record = { }; export default function Dashboard({ locale, translations: i18n }: DashboardProps) { - const [points, setPoints] = useState(null); - const [unreadCount, setUnreadCount] = useState(null); - const [history, setHistory] = useState([]); - const [loadingData, setLoadingData] = useState(true); - const [loadError, setLoadError] = useState(''); - - useEffect(() => { - let alive = true; - setLoadingData(true); - setLoadError(''); - - Promise.all([ - getPointsBalance(), - getUnreadNotificationCount(), - getAgentHistory(), - ]) - .then(([nextPoints, nextUnreadCount, nextHistory]) => { - if (!alive) return; - setPoints(nextPoints); - setUnreadCount(nextUnreadCount); - setHistory(mapHistoryMessagesToItems(nextHistory.messages).slice(0, 4)); - }) - .catch((error: unknown) => { - if (!alive) return; - setLoadError(error instanceof Error ? error.message : 'Failed to load dashboard data'); - }) - .finally(() => { - if (alive) setLoadingData(false); - }); - - return () => { - alive = false; - }; - }, []); + const pointsState = usePoints(); + const unreadState = useUnreadCount(); + const historyState = useHistorySummary(); + const points = pointsState.data; + const unreadCount = unreadState.data; + const history = historyState.data ? mapHistoryMessagesToItems(historyState.data.messages).slice(0, 4) : []; + const loadingData = historyState.loading; + const loadErrorSource = pointsState.error || unreadState.error || historyState.error; + const loadError = loadErrorSource instanceof Error ? loadErrorSource.message : loadErrorSource ? 'Failed to load dashboard data' : ''; const unreadNum = unreadCount?.count ?? 0; const availablePoints = points?.availableBalance; diff --git a/web/src/components/DivinationProcessingOverlay.tsx b/web/src/components/DivinationProcessingOverlay.tsx index 82b0d1f..5878aea 100644 --- a/web/src/components/DivinationProcessingOverlay.tsx +++ b/web/src/components/DivinationProcessingOverlay.tsx @@ -14,6 +14,7 @@ import { type YaoType, type DivinationResultData, } from '../lib/api'; +import { invalidateHistory, invalidatePoints } from '../lib/resources'; // 八卦卡片数据 - 使用 Flutter 的文本 const I_CHING_CARDS = { @@ -154,6 +155,7 @@ export default function DivinationProcessingOverlay({ try { // 1. 提交起卦请求 const { threadId, runId } = await enqueueDivinationRun(params, yaoStates); + invalidatePoints(); if (aborted) return; @@ -173,7 +175,7 @@ export default function DivinationProcessingOverlay({ let conclusion = ''; let focusPoints: string[] = []; let advice: string[] = []; - let keywords: string[] = []; + let keywords = ''; let answer = ''; let status: 'success' | 'failed' | 'refused' = 'success'; @@ -253,6 +255,8 @@ export default function DivinationProcessingOverlay({ setResult(result); } setStep('done'); + invalidatePoints(); + invalidateHistory(threadId); break; } } diff --git a/web/src/components/DivinationResultPage.tsx b/web/src/components/DivinationResultPage.tsx index 9969b06..d15272d 100644 --- a/web/src/components/DivinationResultPage.tsx +++ b/web/src/components/DivinationResultPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { getAgentHistoryByThread, historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api'; +import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api'; +import { useHistoryThread } from '../lib/resources'; import Icon from './Icon'; interface Props { @@ -339,13 +340,12 @@ export default function DivinationResultPage({ locale, translations: t }: Props) const location = useLocation(); const navigate = useNavigate(); const { id: threadId } = useParams<{ id: string }>(); + const threadState = useHistoryThread(threadId); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [canFollowUp, setCanFollowUp] = useState(true); useEffect(() => { - let alive = true; - // 1. Try router state (from divination flow) const state = location.state as { result?: DivinationResultData } | null; if (state?.result) { @@ -369,24 +369,22 @@ export default function DivinationResultPage({ locale, translations: t }: Props) // 3. Fetch by threadId (from history flow) if (threadId) { setLoading(true); - getAgentHistoryByThread(threadId) - .then((snapshot) => { - if (!alive) return; - const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant'); - if (!assistantMsg) return; + if (threadState.data) { + const assistantMsg = threadState.data.messages.find((m) => m.role === 'assistant'); + if (assistantMsg) { const resultData = historyMessageToResultData(assistantMsg); if (resultData) setData(resultData); - const userCount = snapshot.messages.filter((m) => m.role === 'user').length; - setCanFollowUp(userCount < 2); - }) - .catch(() => { /* ignore */ }) - .finally(() => { if (alive) setLoading(false); }); + } + const userCount = threadState.data.messages.filter((m) => m.role === 'user').length; + setCanFollowUp(userCount < 2); + setLoading(false); + } else if (!threadState.loading) { + setLoading(false); + } } else { setLoading(false); } - - return () => { alive = false; }; - }, [location.state, threadId]); + }, [location.state, threadId, threadState.data, threadState.loading]); // Redirect if no data and not loading useEffect(() => { diff --git a/web/src/components/GeneralSettingsPage.tsx b/web/src/components/GeneralSettingsPage.tsx index 3c4a029..5d212ae 100644 --- a/web/src/components/GeneralSettingsPage.tsx +++ b/web/src/components/GeneralSettingsPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { getUserProfile, updateUserSettings, type UserProfile, type ProfileSettings } from '../lib/api'; -import { localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth'; +import type { ProfileSettings } from '../lib/api'; +import { backendLanguageToLocale } from '../lib/auth'; +import { updateSettingsResource, useProfile } from '../lib/resources'; interface Props { locale: string; @@ -40,9 +41,8 @@ function getDefaultSettings(): ProfileSettings { export default function GeneralSettingsPage({ locale, general: g }: Props) { const navigate = useNavigate(); - const [profile, setProfile] = useState(null); + const profileState = useProfile(); const [settings, setSettings] = useState(getDefaultSettings()); - const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [showLanguageModal, setShowLanguageModal] = useState(false); const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null); @@ -52,26 +52,14 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) { const allowNotifications = settings.notification.allow_notifications; useEffect(() => { - setLoading(true); - getUserProfile() - .then((data) => { - setProfile(data); - if (data.settings) { - setSettings(data.settings); - } - }) - .catch(() => { - // Use defaults - }) - .finally(() => setLoading(false)); - }, []); + if (profileState.data?.settings) setSettings(profileState.data.settings); + }, [profileState.data]); const saveSettings = async (newSettings: ProfileSettings): Promise => { setSaving(true); try { - const updated = await updateUserSettings({ settings: newSettings }); + const updated = await updateSettingsResource(newSettings); setSettings(updated.settings); - setProfile(updated); return true; } catch { return false; @@ -135,7 +123,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) { const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage; - if (loading) { + if (profileState.loading) { return (
{locale === 'en' ? 'Loading...' : '加载中...'}
diff --git a/web/src/components/HistoryFollowUpPage.tsx b/web/src/components/HistoryFollowUpPage.tsx index 7b3d9f7..135653b 100644 --- a/web/src/components/HistoryFollowUpPage.tsx +++ b/web/src/components/HistoryFollowUpPage.tsx @@ -1,12 +1,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { - getAgentHistoryByThread, historyMessageToResultData, enqueueFollowUpRun, streamDivinationEvents, type DivinationResultData, } from '../lib/api'; +import { getHistoryThreadResource, invalidateHistory, invalidatePoints } from '../lib/resources'; import Icon from './Icon'; interface Props { @@ -91,7 +91,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) { } try { - const snapshot = await getAgentHistoryByThread(threadId); + const snapshot = await getHistoryThreadResource(threadId); if (!alive) return; // Extract result data from first assistant message @@ -162,6 +162,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) { try { const { runId } = await enqueueFollowUpRun(threadId, text, resultData); + invalidatePoints(); let answer = ''; for await (const event of streamDivinationEvents(threadId, runId)) { @@ -182,7 +183,9 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) { ); // Reload history to get server-side message IDs - const snapshot = await getAgentHistoryByThread(threadId); + invalidateHistory(threadId); + invalidatePoints(); + const snapshot = await getHistoryThreadResource(threadId, true); const chatMessages: ChatMessage[] = snapshot.messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ @@ -195,7 +198,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) { setError(err instanceof Error ? err.message : 'Failed to send follow-up'); // Reload history to restore correct state try { - const snapshot = await getAgentHistoryByThread(threadId); + const snapshot = await getHistoryThreadResource(threadId, true); const chatMessages: ChatMessage[] = snapshot.messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ diff --git a/web/src/components/HistoryListPage.tsx b/web/src/components/HistoryListPage.tsx index df6e029..6102c56 100644 --- a/web/src/components/HistoryListPage.tsx +++ b/web/src/components/HistoryListPage.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api'; +import { mapHistoryMessagesToItems, type HistoryItem } from '../lib/api'; +import { useHistoryList } from '../lib/resources'; import Icon from './Icon'; interface Props { @@ -58,36 +59,13 @@ const RATING_COLORS: Record = { export default function HistoryListPage({ locale, history: i18n }: Props) { const navigate = useNavigate(); - const [allItems, setAllItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); + const historyState = useHistoryList(); const [selectedId, setSelectedId] = useState(null); const [activeFilter, setActiveFilter] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); - - // 获取历史数据 - useEffect(() => { - let alive = true; - setLoading(true); - setError(''); - - getAgentHistory() - .then((data) => { - if (!alive) return; - setAllItems(mapHistoryMessagesToItems(data.messages)); - }) - .catch((err) => { - if (!alive) return; - setError(err instanceof Error ? err.message : 'Failed to load history'); - }) - .finally(() => { - if (alive) setLoading(false); - }); - - return () => { - alive = false; - }; - }, []); + const allItems = useMemo(() => historyState.data ? mapHistoryMessagesToItems(historyState.data.messages) : [], [historyState.data]); + const loading = historyState.loading; + const error = historyState.error instanceof Error ? historyState.error.message : historyState.error ? 'Failed to load history' : ''; const stats = useMemo(() => { const total = allItems.length; diff --git a/web/src/components/ManualDivinationPage.tsx b/web/src/components/ManualDivinationPage.tsx index 949f873..a0ee184 100644 --- a/web/src/components/ManualDivinationPage.tsx +++ b/web/src/components/ManualDivinationPage.tsx @@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import Icon from './Icon'; import DivinationProcessingOverlay from './DivinationProcessingOverlay'; -import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData } from '../lib/api'; +import type { DivinationResultData } from '../lib/api'; +import { updateSettingsResource, usePoints } from '../lib/resources'; import { useUserSettings } from './AppShell'; interface Props { @@ -192,13 +193,14 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { const text = copy[locale as keyof typeof copy] ?? copy.zh; const cats = useMemo(() => d.categories.split(','), [d.categories]); const navigate = useNavigate(); - const [category, setCategory] = useState(cats[0]); - const [question, setQuestion] = useState(text.defaultQuestion); + const [category, setCategory] = useState(cats[0]); + const [question, setQuestion] = useState(text.defaultQuestion); const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date())); const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']); const [yaoResults, setYaoResults] = useState([]); const [guideStep, setGuideStep] = useState(null); - const [points, setPoints] = useState(null); + const pointsState = usePoints(); + const points = pointsState.data ?? null; const [editingIndex, setEditingIndex] = useState(null); const [showProcessing, setShowProcessing] = useState(false); const [showConfirm, setShowConfirm] = useState(false); @@ -249,7 +251,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { }, }; try { - const updated = await updateUserSettings({ settings: updatedSettings }); + const updated = await updateSettingsResource(updatedSettings); setUserProfile(updated); } catch { // Silently fail - tutorial shown state is non-critical @@ -390,10 +392,6 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) { setCategory(cats[0]); }, [cats]); - useEffect(() => { - getPointsBalance().then(setPoints).catch(() => {}); - }, []); - // Track mobile state on resize useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < 1280); diff --git a/web/src/components/NotificationPage.tsx b/web/src/components/NotificationPage.tsx index 67d107d..ca8131b 100644 --- a/web/src/components/NotificationPage.tsx +++ b/web/src/components/NotificationPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { NotificationItem } from '../lib/api'; -import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../lib/api'; +import { markAllNotificationsReadResource, markNotificationReadResource, useNotifications } from '../lib/resources'; interface Props { locale: string; @@ -52,25 +52,13 @@ function formatFullTime(dateStr: string, locale: string): string { } export default function NotificationPage({ locale, notifications: n }: Props) { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const notificationsState = useNotifications(locale); const [selectedItem, setSelectedItem] = useState(null); const [markingAll, setMarkingAll] = useState(false); const [toast, setToast] = useState(null); - - const fetchNotifications = useCallback(() => { - setLoading(true); - setError(null); - getNotifications(locale) - .then((res) => setItems(res.items)) - .catch((err) => setError(err.message || n.error)) - .finally(() => setLoading(false)); - }, [locale, n.error]); - - useEffect(() => { - fetchNotifications(); - }, [fetchNotifications]); + const items = useMemo(() => notificationsState.data?.items ?? [], [notificationsState.data]); + const loading = notificationsState.loading; + const error = notificationsState.error instanceof Error ? notificationsState.error.message : notificationsState.error ? n.error : null; useEffect(() => { if (toast) { @@ -83,8 +71,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) { setSelectedItem(item); if (!item.isRead) { try { - const updated = await markNotificationRead(item.id, locale); - setItems((prev) => prev.map((i) => (i.id === item.id ? updated : i))); + await markNotificationReadResource(item.id, locale); } catch { // ignore mark read error } @@ -97,8 +84,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) { setMarkingAll(true); try { - await markAllNotificationsRead(); - setItems((prev) => prev.map((i) => ({ ...i, isRead: true }))); + await markAllNotificationsReadResource(locale); setToast(n.markAllReadDone); } catch { // ignore error diff --git a/web/src/components/ProfileDetailPage.tsx b/web/src/components/ProfileDetailPage.tsx index de35f6d..7b60086 100644 --- a/web/src/components/ProfileDetailPage.tsx +++ b/web/src/components/ProfileDetailPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { getUserProfile, updateUserProfile, uploadAvatar, type UserProfile } from '../lib/api'; import { getAuth } from '../lib/auth'; +import { updateProfileResource, uploadAvatarResource, useProfile } from '../lib/resources'; interface Props { locale: string; @@ -57,28 +57,25 @@ async function compressImage(file: File, maxWidth = 512, maxHeight = 512, qualit export default function ProfileDetailPage({ locale, profile: p }: Props) { const navigate = useNavigate(); - const [profile, setProfile] = useState(null); + const profileState = useProfile(); + const profile = profileState.data ?? null; const [displayName, setDisplayName] = useState(''); const [bio, setBio] = useState(''); - const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const fileInputRef = useRef(null); - // Load profile on mount useEffect(() => { - setLoading(true); - getUserProfile() - .then((data) => { - setProfile(data); - setDisplayName(data.display_name || ''); - setBio(data.bio || ''); - }) - .catch((err) => setError(err.message || 'Failed to load profile')) - .finally(() => setLoading(false)); - }, []); + if (!profileState.data) return; + setDisplayName(profileState.data.display_name || ''); + setBio(profileState.data.bio || ''); + }, [profileState.data]); + + useEffect(() => { + if (profileState.error instanceof Error) setError(profileState.error.message || 'Failed to load profile'); + }, [profileState.error]); // Clear messages after 3 seconds useEffect(() => { @@ -92,7 +89,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) { setSaving(true); setError(null); try { - await updateUserProfile({ + await updateProfileResource({ display_name: displayName || undefined, bio: bio || undefined, }); @@ -134,8 +131,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) { throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片'); } - const updated = await uploadAvatar(compressedFile); - setProfile(updated); + await uploadAvatarResource(compressedFile); setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to upload'); @@ -146,7 +142,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) { } }; - if (loading) { + if (profileState.loading) { return (
{locale === 'en' ? 'Loading...' : '加载中...'}
diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index 0a2a3e5..7f8ba90 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react'; import { logout, getAuth } from '../lib/auth'; -import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api'; +import { usePoints, useProfile } from '../lib/resources'; interface Props { locale: string; @@ -9,25 +8,11 @@ interface Props { } export default function SettingsPage({ locale, settings: s }: Props) { - const [profile, setProfile] = useState(null); - const [points, setPoints] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - setLoading(true); - Promise.all([ - getUserProfile(), - getPointsBalance(), - ]) - .then(([profileData, pointsData]) => { - setProfile(profileData); - setPoints(pointsData); - }) - .catch(() => { - // ignore errors - }) - .finally(() => setLoading(false)); - }, []); + const profileState = useProfile(); + const pointsState = usePoints(); + const profile = profileState.data ?? null; + const points = pointsState.data ?? null; + const loading = profileState.loading || pointsState.loading; const handleLogout = () => { if (confirm(s.logoutConfirm)) { @@ -38,8 +23,8 @@ export default function SettingsPage({ locale, settings: s }: Props) { }; const authEmail = getAuth()?.user?.email; - const displayName = loading ? '' : (profile?.display_name || profile?.email?.split('@')[0] || authEmail?.split('@')[0] || ''); - const email = loading ? '' : (profile?.email || authEmail || ''); + const displayName = loading ? '' : (profile?.display_name || authEmail?.split('@')[0] || ''); + const email = loading ? '' : (authEmail || ''); const bio = profile?.bio || ''; return ( diff --git a/web/src/components/StorePage.tsx b/web/src/components/StorePage.tsx index 1461a13..a9041d6 100644 --- a/web/src/components/StorePage.tsx +++ b/web/src/components/StorePage.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react'; -import type { PointsBalance, PackageInfo } from '../lib/api'; -import { getPointsBalance, getPackages, invalidatePointsCache } from '../lib/api'; +import { useMemo } from 'react'; +import { usePackages, usePoints } from '../lib/resources'; interface Props { locale: string; @@ -50,49 +49,35 @@ function SidePanel({ s }: { s: Props['store'] }) { } export default function StorePage({ store: s, pricing: p }: Props) { - const [points, setPoints] = useState(null); - const [packages, setPackages] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - setLoading(true); - Promise.all([ - getPointsBalance(), - getPackages(), - ]) - .then(([pointsData, packagesData]) => { - setPoints(pointsData); - // Map backend packages to display format - const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => { - const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1'; - return { - name: p[`${key}Name` as keyof typeof p] || pkg.productCode, - badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '', - price: p[`${key}Price` as keyof typeof p] || '', - credits: `${pkg.credits} ${s.pointsLabel}`, - desc: p[`${key}Desc` as keyof typeof p] || '', - featured: pkg.productCode === 'popular_pack', // 只有常用加量包是推荐 - productCode: pkg.productCode, - appStoreProductId: pkg.appStoreProductId, - starterEligible: pkg.starterEligible, - isStarter: pkg.isStarter, - }; - }); - // Sort by sortOrder - displayPkgs.sort((a, b) => { - const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode); - const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode); - return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0); - }); - setPackages(displayPkgs); - }) - .catch(() => { - // Fallback to static data if API fails - setPoints({ balance: 0, frozenBalance: 0, availableBalance: 0, runCost: 20, canRun: false }); - setPackages([]); - }) - .finally(() => setLoading(false)); - }, [p, s.pointsLabel]); + const pointsState = usePoints(); + const packagesState = usePackages(); + const points = pointsState.data ?? null; + const packages = useMemo(() => { + const packagesData = packagesState.data; + if (!packagesData) return []; + const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => { + const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1'; + return { + name: p[`${key}Name` as keyof typeof p] || pkg.productCode, + badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '', + price: p[`${key}Price` as keyof typeof p] || '', + credits: `${pkg.credits} ${s.pointsLabel}`, + desc: p[`${key}Desc` as keyof typeof p] || '', + featured: pkg.productCode === 'popular_pack', + productCode: pkg.productCode, + appStoreProductId: pkg.appStoreProductId, + starterEligible: pkg.starterEligible, + isStarter: pkg.isStarter, + }; + }); + displayPkgs.sort((a, b) => { + const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode); + const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode); + return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0); + }); + return displayPkgs; + }, [packagesState.data, p, s.pointsLabel]); + const loading = pointsState.loading || packagesState.loading; return (
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c1abf84..dbaf005 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -139,23 +139,12 @@ export interface PackagesResponse { packages: PackageInfo[]; } -// Points cache with TTL -let pointsCache: { data: PointsBalance; expiry: number } | null = null; -const POINTS_CACHE_TTL = 60 * 1000; // 1 minute - -export function getPointsBalance(useCache = true): Promise { - const now = Date.now(); - if (useCache && pointsCache && pointsCache.expiry > now) { - return Promise.resolve(pointsCache.data); - } - return authFetch(API_ROUTES.points.balance).then((data) => { - pointsCache = { data, expiry: now + POINTS_CACHE_TTL }; - return data; - }); +export function getPointsBalance(): Promise { + return authFetch(API_ROUTES.points.balance); } export function invalidatePointsCache(): void { - pointsCache = null; + // Points caching lives in resources.ts. Kept for older imports during rollout. } export function getPackages(): Promise { diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index b1803b4..dccfcc9 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -5,6 +5,7 @@ import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client'; import { API_ROUTES } from './api-routes'; +import { clearAll as clearDataCache } from './data-client'; const STORAGE_KEY = 'meeyao_auth'; @@ -78,6 +79,7 @@ export function setAuth(data: AuthData): void { export function clearAuth(): void { localStorage.removeItem(STORAGE_KEY); + clearDataCache(); } // --- Token status --- diff --git a/web/src/lib/data-client.ts b/web/src/lib/data-client.ts new file mode 100644 index 0000000..660f924 --- /dev/null +++ b/web/src/lib/data-client.ts @@ -0,0 +1,149 @@ +export type CacheKey = readonly string[]; + +interface CacheEntry { + data?: T; + error?: unknown; + updatedAt: number; + expiresAt: number; + promise?: Promise; +} + +export interface QueryOptions { + key: CacheKey; + ttlMs: number; + fetcher: () => Promise; + staleWhileRevalidate?: boolean; + force?: boolean; +} + +type Listener = () => void; + +const cache = new Map>(); +const listeners = new Map>(); + +function keyToString(key: CacheKey): string { + return JSON.stringify(key); +} + +function isPrefix(key: CacheKey, prefix: CacheKey): boolean { + return prefix.every((part, index) => key[index] === part); +} + +function notify(serializedKey: string): void { + listeners.get(serializedKey)?.forEach((listener) => listener()); +} + +function notifyPrefix(prefix: CacheKey): void { + for (const serializedKey of listeners.keys()) { + const parsedKey = JSON.parse(serializedKey) as string[]; + if (isPrefix(parsedKey, prefix)) notify(serializedKey); + } +} + +function startFetch(serializedKey: string, ttlMs: number, fetcher: () => Promise): Promise { + const now = Date.now(); + const existing = cache.get(serializedKey) as CacheEntry | undefined; + const promise = fetcher() + .then((data) => { + cache.set(serializedKey, { + data, + updatedAt: Date.now(), + expiresAt: Date.now() + ttlMs, + }); + notify(serializedKey); + return data; + }) + .catch((error) => { + cache.set(serializedKey, { + data: existing?.data, + error, + updatedAt: existing?.updatedAt ?? now, + expiresAt: existing?.data === undefined ? now : existing.expiresAt, + }); + notify(serializedKey); + throw error; + }); + + cache.set(serializedKey, { + ...existing, + updatedAt: existing?.updatedAt ?? now, + expiresAt: existing?.expiresAt ?? now, + promise, + }); + notify(serializedKey); + return promise; +} + +export function query({ + key, + ttlMs, + fetcher, + staleWhileRevalidate = true, + force = false, +}: QueryOptions): Promise { + const serializedKey = keyToString(key); + const entry = cache.get(serializedKey) as CacheEntry | undefined; + const now = Date.now(); + + if (!force && entry?.promise) return entry.promise; + if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data); + + if (!force && staleWhileRevalidate && entry?.data !== undefined) { + void startFetch(serializedKey, ttlMs, fetcher).catch((error) => { + console.debug('[data-client] Background refresh failed', error); + }); + return Promise.resolve(entry.data); + } + + return startFetch(serializedKey, ttlMs, fetcher); +} + +export function prefetch(options: QueryOptions): void { + void query(options).catch((error) => { + console.debug('[data-client] Prefetch failed', error); + }); +} + +export function peek(key: CacheKey): T | undefined { + return (cache.get(keyToString(key)) as CacheEntry | undefined)?.data; +} + +export function getEntry(key: CacheKey): CacheEntry | undefined { + return cache.get(keyToString(key)) as CacheEntry | undefined; +} + +export function set(key: CacheKey, data: T, ttlMs: number): void { + cache.set(keyToString(key), { + data, + updatedAt: Date.now(), + expiresAt: Date.now() + ttlMs, + }); + notify(keyToString(key)); +} + +export function invalidate(prefix: CacheKey): void { + for (const serializedKey of Array.from(cache.keys())) { + const parsedKey = JSON.parse(serializedKey) as string[]; + if (isPrefix(parsedKey, prefix)) { + cache.delete(serializedKey); + notify(serializedKey); + } + } + notifyPrefix(prefix); +} + +export function clearAll(): void { + cache.clear(); + for (const serializedKey of listeners.keys()) notify(serializedKey); +} + +export function subscribe(key: CacheKey, listener: Listener): () => void { + const serializedKey = keyToString(key); + const keyListeners = listeners.get(serializedKey) ?? new Set(); + keyListeners.add(listener); + listeners.set(serializedKey, keyListeners); + return () => { + keyListeners.delete(listener); + if (keyListeners.size === 0) listeners.delete(serializedKey); + }; +} diff --git a/web/src/lib/resources.ts b/web/src/lib/resources.ts new file mode 100644 index 0000000..2f7d206 --- /dev/null +++ b/web/src/lib/resources.ts @@ -0,0 +1,397 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + getAgentHistory, + getAgentHistoryByThread, + getNotifications, + getPackages, + getPointsBalance, + getUnreadNotificationCount, + getUserProfile, + markAllNotificationsRead, + markNotificationRead, + updateUserProfile, + updateUserSettings, + uploadAvatar, + type HistorySnapshot, + type NotificationItem, + type NotificationListResponse, + type PackageInfo, + type PackagesResponse, + type PointsBalance, + type ProfileSettings, + type UnreadCount, + type UpdateProfileRequest, + type UserProfile, +} from './api'; +import { + getEntry, + invalidate, + peek, + prefetch, + query, + set, + subscribe, + type CacheKey, + type QueryOptions, +} from './data-client'; + +const PROFILE_TTL = 5 * 60_000; +const POINTS_TTL = 60_000; +const PACKAGES_TTL = 30 * 60_000; +const HISTORY_TTL = 60_000; +const HISTORY_THREAD_TTL = 5 * 60_000; +const NOTIFICATIONS_TTL = 60_000; +const UNREAD_TTL = 30_000; + +export const profileKey = ['profile'] as const; +export const pointsBalanceKey = ['points', 'balance'] as const; +export const packagesKey = ['points', 'packages'] as const; +export const historyListKey = ['history', 'list'] as const; +export const historySummaryKey = historyListKey; +export const historyThreadKey = (threadId: string) => ['history', 'thread', threadId] as const; +export const notificationsKey = (locale: string) => ['notifications', 'list', locale] as const; +export const unreadCountKey = ['notifications', 'unread-count'] as const; + +interface ResourceState { + data: T | undefined; + loading: boolean; + refreshing: boolean; + error: unknown; + reload: () => Promise; +} + +type ResourceOptions = QueryOptions & { + enabled?: boolean; +}; + +export function useResource(options: ResourceOptions): ResourceState { + const optionsRef = useRef(options); + optionsRef.current = options; + const keyId = useMemo(() => JSON.stringify(options.key), [options.key]); + const enabled = options.enabled ?? true; + const [data, setDataState] = useState(() => enabled ? peek(options.key) : undefined); + const [loading, setLoading] = useState(() => enabled && peek(options.key) === undefined); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(() => enabled ? getEntry(options.key)?.error : undefined); + + const load = useCallback((force = false) => { + const currentOptions = optionsRef.current; + if (currentOptions.enabled === false) { + return Promise.reject(new Error('Resource is disabled')); + } + const hasCachedData = peek(currentOptions.key) !== undefined; + setLoading(!hasCachedData); + setRefreshing(hasCachedData); + return query({ ...currentOptions, force }) + .then((next) => { + setDataState(next); + setError(undefined); + return next; + }) + .catch((err) => { + setError(err); + throw err; + }) + .finally(() => { + setLoading(false); + setRefreshing(false); + }); + }, []); + + const syncFromCache = useCallback(() => { + const entry = getEntry(optionsRef.current.key); + if (!entry) { + setDataState(undefined); + setError(undefined); + setRefreshing(false); + void load(false).catch(() => undefined); + return; + } + setDataState(entry.data); + setError(entry.error); + setRefreshing(Boolean(entry.promise && entry.data !== undefined)); + }, [load]); + + useEffect(() => { + if (!enabled) return undefined; + return subscribe(optionsRef.current.key, syncFromCache); + }, [enabled, keyId, syncFromCache]); + + useEffect(() => { + if (!enabled) { + setDataState(undefined); + setLoading(false); + setRefreshing(false); + setError(undefined); + return; + } + setDataState(peek(optionsRef.current.key)); + setError(getEntry(optionsRef.current.key)?.error); + void load(false).catch(() => undefined); + }, [enabled, keyId, load]); + + return { + data, + loading, + refreshing, + error, + reload: () => load(true), + }; +} + +function fetchProfileResource(force = false): Promise { + return query({ + key: profileKey, + ttlMs: PROFILE_TTL, + fetcher: getUserProfile, + staleWhileRevalidate: true, + force, + }); +} + +export function getProfileResource(): Promise { + return fetchProfileResource(false); +} + +export function useProfile(): ResourceState { + return useResource({ + key: profileKey, + ttlMs: PROFILE_TTL, + fetcher: getUserProfile, + staleWhileRevalidate: true, + }); +} + +export function setProfileResource(profile: UserProfile): void { + set(profileKey, profile, PROFILE_TTL); +} + +export async function updateProfileResource(input: UpdateProfileRequest): Promise { + const updated = await updateUserProfile(input); + setProfileResource(updated); + return updated; +} + +export async function uploadAvatarResource(file: File): Promise { + const updated = await uploadAvatar(file); + setProfileResource(updated); + return updated; +} + +export async function updateSettingsResource(settings: ProfileSettings): Promise { + const updated = await updateUserSettings({ settings }); + setProfileResource(updated); + return updated; +} + +export function getPointsResource(force = false): Promise { + return query({ + key: pointsBalanceKey, + ttlMs: POINTS_TTL, + fetcher: getPointsBalance, + staleWhileRevalidate: true, + force, + }); +} + +export function usePoints(): ResourceState { + return useResource({ + key: pointsBalanceKey, + ttlMs: POINTS_TTL, + fetcher: getPointsBalance, + staleWhileRevalidate: true, + }); +} + +export function invalidatePoints(): void { + invalidate(pointsBalanceKey); +} + +export function getPackagesResource(force = false): Promise { + return query({ + key: packagesKey, + ttlMs: PACKAGES_TTL, + fetcher: getPackages, + staleWhileRevalidate: true, + force, + }); +} + +export function usePackages(): ResourceState { + return useResource({ + key: packagesKey, + ttlMs: PACKAGES_TTL, + fetcher: getPackages, + staleWhileRevalidate: true, + }); +} + +export function getHistoryListResource(force = false): Promise { + return query({ + key: historyListKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + force, + }); +} + +export function useHistoryList(): ResourceState { + return useResource({ + key: historyListKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + }); +} + +export function getHistorySummaryResource(force = false): Promise { + return query({ + key: historySummaryKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + force, + }); +} + +export function useHistorySummary(): ResourceState { + return useResource({ + key: historySummaryKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + }); +} + +export function getHistoryThreadResource(threadId: string, force = false): Promise { + return query({ + key: historyThreadKey(threadId), + ttlMs: HISTORY_THREAD_TTL, + fetcher: () => getAgentHistoryByThread(threadId), + staleWhileRevalidate: true, + force, + }); +} + +export function useHistoryThread(threadId?: string): ResourceState { + return useResource({ + key: threadId ? historyThreadKey(threadId) : ['history', 'thread', 'missing'], + ttlMs: HISTORY_THREAD_TTL, + fetcher: () => { + if (!threadId) return Promise.reject(new Error('Missing history thread id')); + return getAgentHistoryByThread(threadId); + }, + staleWhileRevalidate: true, + enabled: Boolean(threadId), + }); +} + +export function invalidateHistory(threadId?: string): void { + invalidate(historySummaryKey); + invalidate(historyListKey); + if (threadId) invalidate(historyThreadKey(threadId)); +} + +export function getNotificationsResource(locale: string, force = false): Promise { + return query({ + key: notificationsKey(locale), + ttlMs: NOTIFICATIONS_TTL, + fetcher: () => getNotifications(locale), + staleWhileRevalidate: true, + force, + }); +} + +export function useNotifications(locale: string): ResourceState { + return useResource({ + key: notificationsKey(locale), + ttlMs: NOTIFICATIONS_TTL, + fetcher: () => getNotifications(locale), + staleWhileRevalidate: true, + }); +} + +export function getUnreadCountResource(force = false): Promise { + return query({ + key: unreadCountKey, + ttlMs: UNREAD_TTL, + fetcher: getUnreadNotificationCount, + staleWhileRevalidate: true, + force, + }); +} + +export function useUnreadCount(): ResourceState { + return useResource({ + key: unreadCountKey, + ttlMs: UNREAD_TTL, + fetcher: getUnreadNotificationCount, + staleWhileRevalidate: true, + }); +} + +export async function markNotificationReadResource(id: string, locale: string): Promise { + const updated = await markNotificationRead(id, locale); + const listKey = notificationsKey(locale); + const list = peek(listKey); + if (list) { + const previous = list.items.find((item) => item.id === id); + set(listKey, { + ...list, + items: list.items.map((item) => (item.id === id ? updated : item)), + }, NOTIFICATIONS_TTL); + if (previous && !previous.isRead && updated.isRead) { + const unread = peek(unreadCountKey); + if (unread) set(unreadCountKey, { count: Math.max(0, unread.count - 1) }, UNREAD_TTL); + } + } else { + invalidate(unreadCountKey); + } + return updated; +} + +export async function markAllNotificationsReadResource(locale: string): Promise<{ updatedCount: number }> { + const result = await markAllNotificationsRead(); + const listKey = notificationsKey(locale); + const list = peek(listKey); + if (list) { + const readAt = new Date().toISOString(); + set(listKey, { + ...list, + items: list.items.map((item) => ({ ...item, isRead: true, readAt: item.readAt ?? readAt })), + }, NOTIFICATIONS_TTL); + } + set(unreadCountKey, { count: 0 }, UNREAD_TTL); + return result; +} + +export function prefetchAppBasics(): void { + prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true }); + prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true }); + prefetch({ key: historySummaryKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true }); +} + +export function prefetchForPath(pathname: string, locale: string): void { + if (pathname.includes('/store')) { + prefetch({ key: packagesKey, ttlMs: PACKAGES_TTL, fetcher: getPackages, staleWhileRevalidate: true }); + prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true }); + } else if (pathname.includes('/history')) { + prefetch({ key: historyListKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true }); + } else if (pathname.includes('/notifications')) { + prefetch({ key: notificationsKey(locale), ttlMs: NOTIFICATIONS_TTL, fetcher: () => getNotifications(locale), staleWhileRevalidate: true }); + prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true }); + } else if (pathname.includes('/settings') || pathname.includes('/profile')) { + prefetch({ key: profileKey, ttlMs: PROFILE_TTL, fetcher: getUserProfile, staleWhileRevalidate: true }); + } else if (pathname.includes('/divination')) { + prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true }); + } +} + +export function getCachedProfile(): UserProfile | undefined { + return peek(profileKey); +} + +export function getCachedPackages(): PackageInfo[] | undefined { + return peek(packagesKey)?.packages; +}