perf: optimize web data resources
This commit is contained in:
@@ -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`.
|
- Shared request behavior lives in `web/src/lib/api-client.ts`.
|
||||||
- Auth/session behavior lives in `web/src/lib/auth.ts`.
|
- Auth/session behavior lives in `web/src/lib/auth.ts`.
|
||||||
- Business API functions live in `web/src/lib/api.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 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.
|
- 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.
|
- 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<PointsBalance> {
|
||||||
|
return authFetch<PointsBalance>(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
|
## Layout Rules
|
||||||
|
|
||||||
- Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements.
|
- Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"file": ".trellis/spec/web/index.md", "reason": "Verify request behavior, authenticated routing, and responsive browser checks."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app architecture, API, auth, and performance-sensitive layout rules."}
|
||||||
@@ -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.
|
||||||
@@ -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<T> {
|
||||||
|
data?: T;
|
||||||
|
error?: unknown;
|
||||||
|
updatedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
promise?: Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryOptions<T> {
|
||||||
|
key: CacheKey;
|
||||||
|
ttlMs: number;
|
||||||
|
fetcher: () => Promise<T>;
|
||||||
|
staleWhileRevalidate?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required operations:
|
||||||
|
|
||||||
|
* `query<T>(options): Promise<T>`: returns fresh data, dedupes in-flight request.
|
||||||
|
* `peek<T>(key): T | undefined`: synchronous read for instant UI seeding.
|
||||||
|
* `set<T>(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.
|
||||||
@@ -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.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { useState, useEffect, createContext, useContext, type ReactNode } from '
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth';
|
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
|
// User settings context
|
||||||
interface UserSettingsContextValue {
|
interface UserSettingsContextValue {
|
||||||
@@ -80,11 +82,12 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
refreshAccessToken()
|
refreshAccessToken()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (alive) setAuthUser(data.user);
|
if (alive) setAuthUser(data.user);
|
||||||
return getUserProfile();
|
return getProfileResource();
|
||||||
})
|
})
|
||||||
.then((profile) => {
|
.then((profile) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setUserProfile(profile);
|
setUserProfile(profile);
|
||||||
|
prefetchAppBasics();
|
||||||
|
|
||||||
// Check if URL locale matches user's language preference
|
// Check if URL locale matches user's language preference
|
||||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
|
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
|
||||||
@@ -107,6 +110,14 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
};
|
};
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = getCachedProfile();
|
||||||
|
if (cached) setUserProfile(cached);
|
||||||
|
return subscribe(profileKey, () => {
|
||||||
|
setUserProfile(getCachedProfile() ?? null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination');
|
if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination');
|
||||||
}, [activeNav]);
|
}, [activeNav]);
|
||||||
@@ -116,6 +127,10 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
routerNavigate(href);
|
routerNavigate(href);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prefetchNav = (href: string) => {
|
||||||
|
prefetchForPath(href, locale);
|
||||||
|
};
|
||||||
|
|
||||||
if (checkingAuth || authUser === null) {
|
if (checkingAuth || authUser === null) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
|
||||||
@@ -125,7 +140,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || '';
|
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;
|
const shellAvatarUrl = userProfile?.avatar_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,6 +184,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
</button>
|
</button>
|
||||||
{!sidebarCollapsed && isExpanded && item.sub.map((sub) => (
|
{!sidebarCollapsed && isExpanded && item.sub.map((sub) => (
|
||||||
<a key={sub.id} href={sub.href}
|
<a key={sub.id} href={sub.href}
|
||||||
|
onMouseEnter={() => prefetchNav(sub.href)}
|
||||||
|
onFocus={() => prefetchNav(sub.href)}
|
||||||
onClick={(e) => { e.preventDefault(); navigate(sub.href); setSidebarOpen(false); }}
|
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'}`}>
|
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'}`}>
|
||||||
<span className="text-xs">{activeNav === sub.id ? '●' : '○'}</span>{sub.label}
|
<span className="text-xs">{activeNav === sub.id ? '●' : '○'}</span>{sub.label}
|
||||||
@@ -183,6 +200,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
|
|||||||
if (item.href) {
|
if (item.href) {
|
||||||
return (
|
return (
|
||||||
<a key={item.id} href={item.href}
|
<a key={item.id} href={item.href}
|
||||||
|
onMouseEnter={() => prefetchNav(item.href)}
|
||||||
|
onFocus={() => prefetchNav(item.href)}
|
||||||
onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }}
|
onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }}
|
||||||
title={sidebarCollapsed ? item.label : undefined}
|
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'}`}>
|
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'}`}>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
|
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';
|
import { useUserSettings } from './AppShell';
|
||||||
|
|
||||||
interface Props {
|
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 text = copy[locale as keyof typeof copy] ?? copy.zh;
|
||||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [category, setCategory] = useState(cats[0]);
|
const [category, setCategory] = useState<string>(cats[0]);
|
||||||
const [question, setQuestion] = useState(text.defaultQuestion);
|
const [question, setQuestion] = useState<string>(text.defaultQuestion);
|
||||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
const pointsState = usePoints();
|
||||||
|
const points = pointsState.data ?? null;
|
||||||
const [showProcessing, setShowProcessing] = useState(false);
|
const [showProcessing, setShowProcessing] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const { userProfile, setUserProfile } = useUserSettings();
|
const { userProfile, setUserProfile } = useUserSettings();
|
||||||
@@ -237,7 +239,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const updated = await updateUserSettings({ settings: updatedSettings });
|
const updated = await updateSettingsResource(updatedSettings);
|
||||||
setUserProfile(updated);
|
setUserProfile(updated);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail
|
// Silently fail
|
||||||
@@ -364,10 +366,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
setCategory(cats[0]);
|
setCategory(cats[0]);
|
||||||
}, [cats]);
|
}, [cats]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getPointsBalance().then(setPoints).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { mapHistoryMessagesToItems } from '../lib/api';
|
||||||
import { getPointsBalance, type PointsBalance } from '../lib/api';
|
import { useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
|
||||||
import { getUnreadNotificationCount, type UnreadCount } from '../lib/api';
|
|
||||||
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
|
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -53,40 +51,15 @@ const RATING_COLORS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Dashboard({ locale, translations: i18n }: DashboardProps) {
|
export default function Dashboard({ locale, translations: i18n }: DashboardProps) {
|
||||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
const pointsState = usePoints();
|
||||||
const [unreadCount, setUnreadCount] = useState<UnreadCount | null>(null);
|
const unreadState = useUnreadCount();
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
const historyState = useHistorySummary();
|
||||||
const [loadingData, setLoadingData] = useState(true);
|
const points = pointsState.data;
|
||||||
const [loadError, setLoadError] = useState('');
|
const unreadCount = unreadState.data;
|
||||||
|
const history = historyState.data ? mapHistoryMessagesToItems(historyState.data.messages).slice(0, 4) : [];
|
||||||
useEffect(() => {
|
const loadingData = historyState.loading;
|
||||||
let alive = true;
|
const loadErrorSource = pointsState.error || unreadState.error || historyState.error;
|
||||||
setLoadingData(true);
|
const loadError = loadErrorSource instanceof Error ? loadErrorSource.message : loadErrorSource ? 'Failed to load dashboard data' : '';
|
||||||
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 unreadNum = unreadCount?.count ?? 0;
|
const unreadNum = unreadCount?.count ?? 0;
|
||||||
const availablePoints = points?.availableBalance;
|
const availablePoints = points?.availableBalance;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type YaoType,
|
type YaoType,
|
||||||
type DivinationResultData,
|
type DivinationResultData,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { invalidateHistory, invalidatePoints } from '../lib/resources';
|
||||||
|
|
||||||
// 八卦卡片数据 - 使用 Flutter 的文本
|
// 八卦卡片数据 - 使用 Flutter 的文本
|
||||||
const I_CHING_CARDS = {
|
const I_CHING_CARDS = {
|
||||||
@@ -154,6 +155,7 @@ export default function DivinationProcessingOverlay({
|
|||||||
try {
|
try {
|
||||||
// 1. 提交起卦请求
|
// 1. 提交起卦请求
|
||||||
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
|
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
|
||||||
|
invalidatePoints();
|
||||||
|
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ export default function DivinationProcessingOverlay({
|
|||||||
let conclusion = '';
|
let conclusion = '';
|
||||||
let focusPoints: string[] = [];
|
let focusPoints: string[] = [];
|
||||||
let advice: string[] = [];
|
let advice: string[] = [];
|
||||||
let keywords: string[] = [];
|
let keywords = '';
|
||||||
let answer = '';
|
let answer = '';
|
||||||
let status: 'success' | 'failed' | 'refused' = 'success';
|
let status: 'success' | 'failed' | 'refused' = 'success';
|
||||||
|
|
||||||
@@ -253,6 +255,8 @@ export default function DivinationProcessingOverlay({
|
|||||||
setResult(result);
|
setResult(result);
|
||||||
}
|
}
|
||||||
setStep('done');
|
setStep('done');
|
||||||
|
invalidatePoints();
|
||||||
|
invalidateHistory(threadId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
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';
|
import Icon from './Icon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -339,13 +340,12 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id: threadId } = useParams<{ id: string }>();
|
const { id: threadId } = useParams<{ id: string }>();
|
||||||
|
const threadState = useHistoryThread(threadId);
|
||||||
const [data, setData] = useState<DivinationResultData | null>(null);
|
const [data, setData] = useState<DivinationResultData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [canFollowUp, setCanFollowUp] = useState(true);
|
const [canFollowUp, setCanFollowUp] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
|
||||||
|
|
||||||
// 1. Try router state (from divination flow)
|
// 1. Try router state (from divination flow)
|
||||||
const state = location.state as { result?: DivinationResultData } | null;
|
const state = location.state as { result?: DivinationResultData } | null;
|
||||||
if (state?.result) {
|
if (state?.result) {
|
||||||
@@ -369,24 +369,22 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
|
|||||||
// 3. Fetch by threadId (from history flow)
|
// 3. Fetch by threadId (from history flow)
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getAgentHistoryByThread(threadId)
|
if (threadState.data) {
|
||||||
.then((snapshot) => {
|
const assistantMsg = threadState.data.messages.find((m) => m.role === 'assistant');
|
||||||
if (!alive) return;
|
if (assistantMsg) {
|
||||||
const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant');
|
|
||||||
if (!assistantMsg) return;
|
|
||||||
const resultData = historyMessageToResultData(assistantMsg);
|
const resultData = historyMessageToResultData(assistantMsg);
|
||||||
if (resultData) setData(resultData);
|
if (resultData) setData(resultData);
|
||||||
const userCount = snapshot.messages.filter((m) => m.role === 'user').length;
|
}
|
||||||
setCanFollowUp(userCount < 2);
|
const userCount = threadState.data.messages.filter((m) => m.role === 'user').length;
|
||||||
})
|
setCanFollowUp(userCount < 2);
|
||||||
.catch(() => { /* ignore */ })
|
setLoading(false);
|
||||||
.finally(() => { if (alive) setLoading(false); });
|
} else if (!threadState.loading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}, [location.state, threadId, threadState.data, threadState.loading]);
|
||||||
return () => { alive = false; };
|
|
||||||
}, [location.state, threadId]);
|
|
||||||
|
|
||||||
// Redirect if no data and not loading
|
// Redirect if no data and not loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getUserProfile, updateUserSettings, type UserProfile, type ProfileSettings } from '../lib/api';
|
import type { ProfileSettings } from '../lib/api';
|
||||||
import { localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
|
import { backendLanguageToLocale } from '../lib/auth';
|
||||||
|
import { updateSettingsResource, useProfile } from '../lib/resources';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -40,9 +41,8 @@ function getDefaultSettings(): ProfileSettings {
|
|||||||
|
|
||||||
export default function GeneralSettingsPage({ locale, general: g }: Props) {
|
export default function GeneralSettingsPage({ locale, general: g }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const profileState = useProfile();
|
||||||
const [settings, setSettings] = useState<ProfileSettings>(getDefaultSettings());
|
const [settings, setSettings] = useState<ProfileSettings>(getDefaultSettings());
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showLanguageModal, setShowLanguageModal] = useState(false);
|
const [showLanguageModal, setShowLanguageModal] = useState(false);
|
||||||
const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null);
|
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;
|
const allowNotifications = settings.notification.allow_notifications;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (profileState.data?.settings) setSettings(profileState.data.settings);
|
||||||
getUserProfile()
|
}, [profileState.data]);
|
||||||
.then((data) => {
|
|
||||||
setProfile(data);
|
|
||||||
if (data.settings) {
|
|
||||||
setSettings(data.settings);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Use defaults
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveSettings = async (newSettings: ProfileSettings): Promise<boolean> => {
|
const saveSettings = async (newSettings: ProfileSettings): Promise<boolean> => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await updateUserSettings({ settings: newSettings });
|
const updated = await updateSettingsResource(newSettings);
|
||||||
setSettings(updated.settings);
|
setSettings(updated.settings);
|
||||||
setProfile(updated);
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -135,7 +123,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
|
|||||||
|
|
||||||
const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage;
|
const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage;
|
||||||
|
|
||||||
if (loading) {
|
if (profileState.loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 min-h-full">
|
<div className="flex flex-col gap-6 min-h-full">
|
||||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
getAgentHistoryByThread,
|
|
||||||
historyMessageToResultData,
|
historyMessageToResultData,
|
||||||
enqueueFollowUpRun,
|
enqueueFollowUpRun,
|
||||||
streamDivinationEvents,
|
streamDivinationEvents,
|
||||||
type DivinationResultData,
|
type DivinationResultData,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { getHistoryThreadResource, invalidateHistory, invalidatePoints } from '../lib/resources';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -91,7 +91,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await getAgentHistoryByThread(threadId);
|
const snapshot = await getHistoryThreadResource(threadId);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
|
||||||
// Extract result data from first assistant message
|
// Extract result data from first assistant message
|
||||||
@@ -162,6 +162,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
|
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
|
||||||
|
invalidatePoints();
|
||||||
|
|
||||||
let answer = '';
|
let answer = '';
|
||||||
for await (const event of streamDivinationEvents(threadId, runId)) {
|
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
|
// 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
|
const chatMessages: ChatMessage[] = snapshot.messages
|
||||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||||
.map((m) => ({
|
.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');
|
setError(err instanceof Error ? err.message : 'Failed to send follow-up');
|
||||||
// Reload history to restore correct state
|
// Reload history to restore correct state
|
||||||
try {
|
try {
|
||||||
const snapshot = await getAgentHistoryByThread(threadId);
|
const snapshot = await getHistoryThreadResource(threadId, true);
|
||||||
const chatMessages: ChatMessage[] = snapshot.messages
|
const chatMessages: ChatMessage[] = snapshot.messages
|
||||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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';
|
import Icon from './Icon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,36 +59,13 @@ const RATING_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function HistoryListPage({ locale, history: i18n }: Props) {
|
export default function HistoryListPage({ locale, history: i18n }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [allItems, setAllItems] = useState<HistoryItem[]>([]);
|
const historyState = useHistoryList();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [activeFilter, setActiveFilter] = useState<CategoryKey | 'all'>('all');
|
const [activeFilter, setActiveFilter] = useState<CategoryKey | 'all'>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const allItems = useMemo(() => historyState.data ? mapHistoryMessagesToItems(historyState.data.messages) : [], [historyState.data]);
|
||||||
// 获取历史数据
|
const loading = historyState.loading;
|
||||||
useEffect(() => {
|
const error = historyState.error instanceof Error ? historyState.error.message : historyState.error ? 'Failed to load history' : '';
|
||||||
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 stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = allItems.length;
|
const total = allItems.length;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
|
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';
|
import { useUserSettings } from './AppShell';
|
||||||
|
|
||||||
interface Props {
|
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 text = copy[locale as keyof typeof copy] ?? copy.zh;
|
||||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [category, setCategory] = useState(cats[0]);
|
const [category, setCategory] = useState<string>(cats[0]);
|
||||||
const [question, setQuestion] = useState(text.defaultQuestion);
|
const [question, setQuestion] = useState<string>(text.defaultQuestion);
|
||||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||||
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
||||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
const pointsState = usePoints();
|
||||||
|
const points = pointsState.data ?? null;
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [showProcessing, setShowProcessing] = useState(false);
|
const [showProcessing, setShowProcessing] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
@@ -249,7 +251,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const updated = await updateUserSettings({ settings: updatedSettings });
|
const updated = await updateSettingsResource(updatedSettings);
|
||||||
setUserProfile(updated);
|
setUserProfile(updated);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail - tutorial shown state is non-critical
|
// Silently fail - tutorial shown state is non-critical
|
||||||
@@ -390,10 +392,6 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
setCategory(cats[0]);
|
setCategory(cats[0]);
|
||||||
}, [cats]);
|
}, [cats]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getPointsBalance().then(setPoints).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Track mobile state on resize
|
// Track mobile state on resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { NotificationItem } from '../lib/api';
|
import type { NotificationItem } from '../lib/api';
|
||||||
import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../lib/api';
|
import { markAllNotificationsReadResource, markNotificationReadResource, useNotifications } from '../lib/resources';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -52,25 +52,13 @@ function formatFullTime(dateStr: string, locale: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificationPage({ locale, notifications: n }: Props) {
|
export default function NotificationPage({ locale, notifications: n }: Props) {
|
||||||
const [items, setItems] = useState<NotificationItem[]>([]);
|
const notificationsState = useNotifications(locale);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<NotificationItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<NotificationItem | null>(null);
|
||||||
const [markingAll, setMarkingAll] = useState(false);
|
const [markingAll, setMarkingAll] = useState(false);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const items = useMemo(() => notificationsState.data?.items ?? [], [notificationsState.data]);
|
||||||
const fetchNotifications = useCallback(() => {
|
const loading = notificationsState.loading;
|
||||||
setLoading(true);
|
const error = notificationsState.error instanceof Error ? notificationsState.error.message : notificationsState.error ? n.error : null;
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast) {
|
if (toast) {
|
||||||
@@ -83,8 +71,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) {
|
|||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
if (!item.isRead) {
|
if (!item.isRead) {
|
||||||
try {
|
try {
|
||||||
const updated = await markNotificationRead(item.id, locale);
|
await markNotificationReadResource(item.id, locale);
|
||||||
setItems((prev) => prev.map((i) => (i.id === item.id ? updated : i)));
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore mark read error
|
// ignore mark read error
|
||||||
}
|
}
|
||||||
@@ -97,8 +84,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) {
|
|||||||
|
|
||||||
setMarkingAll(true);
|
setMarkingAll(true);
|
||||||
try {
|
try {
|
||||||
await markAllNotificationsRead();
|
await markAllNotificationsReadResource(locale);
|
||||||
setItems((prev) => prev.map((i) => ({ ...i, isRead: true })));
|
|
||||||
setToast(n.markAllReadDone);
|
setToast(n.markAllReadDone);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore error
|
// ignore error
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getUserProfile, updateUserProfile, uploadAvatar, type UserProfile } from '../lib/api';
|
|
||||||
import { getAuth } from '../lib/auth';
|
import { getAuth } from '../lib/auth';
|
||||||
|
import { updateProfileResource, uploadAvatarResource, useProfile } from '../lib/resources';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -57,28 +57,25 @@ async function compressImage(file: File, maxWidth = 512, maxHeight = 512, qualit
|
|||||||
|
|
||||||
export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const profileState = useProfile();
|
||||||
|
const profile = profileState.data ?? null;
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Load profile on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (!profileState.data) return;
|
||||||
getUserProfile()
|
setDisplayName(profileState.data.display_name || '');
|
||||||
.then((data) => {
|
setBio(profileState.data.bio || '');
|
||||||
setProfile(data);
|
}, [profileState.data]);
|
||||||
setDisplayName(data.display_name || '');
|
|
||||||
setBio(data.bio || '');
|
useEffect(() => {
|
||||||
})
|
if (profileState.error instanceof Error) setError(profileState.error.message || 'Failed to load profile');
|
||||||
.catch((err) => setError(err.message || 'Failed to load profile'))
|
}, [profileState.error]);
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Clear messages after 3 seconds
|
// Clear messages after 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,7 +89,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await updateUserProfile({
|
await updateProfileResource({
|
||||||
display_name: displayName || undefined,
|
display_name: displayName || undefined,
|
||||||
bio: bio || 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' : '图片太大,请选择更小的图片');
|
throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await uploadAvatar(compressedFile);
|
await uploadAvatarResource(compressedFile);
|
||||||
setProfile(updated);
|
|
||||||
setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新');
|
setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to upload');
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6 min-h-full">
|
<div className="flex flex-col gap-6 min-h-full">
|
||||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { logout, getAuth } from '../lib/auth';
|
import { logout, getAuth } from '../lib/auth';
|
||||||
import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api';
|
import { usePoints, useProfile } from '../lib/resources';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -9,25 +8,11 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ locale, settings: s }: Props) {
|
export default function SettingsPage({ locale, settings: s }: Props) {
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const profileState = useProfile();
|
||||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
const pointsState = usePoints();
|
||||||
const [loading, setLoading] = useState(true);
|
const profile = profileState.data ?? null;
|
||||||
|
const points = pointsState.data ?? null;
|
||||||
useEffect(() => {
|
const loading = profileState.loading || pointsState.loading;
|
||||||
setLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
getUserProfile(),
|
|
||||||
getPointsBalance(),
|
|
||||||
])
|
|
||||||
.then(([profileData, pointsData]) => {
|
|
||||||
setProfile(profileData);
|
|
||||||
setPoints(pointsData);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// ignore errors
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (confirm(s.logoutConfirm)) {
|
if (confirm(s.logoutConfirm)) {
|
||||||
@@ -38,8 +23,8 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const authEmail = getAuth()?.user?.email;
|
const authEmail = getAuth()?.user?.email;
|
||||||
const displayName = loading ? '' : (profile?.display_name || profile?.email?.split('@')[0] || authEmail?.split('@')[0] || '');
|
const displayName = loading ? '' : (profile?.display_name || authEmail?.split('@')[0] || '');
|
||||||
const email = loading ? '' : (profile?.email || authEmail || '');
|
const email = loading ? '' : (authEmail || '');
|
||||||
const bio = profile?.bio || '';
|
const bio = profile?.bio || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { PointsBalance, PackageInfo } from '../lib/api';
|
import { usePackages, usePoints } from '../lib/resources';
|
||||||
import { getPointsBalance, getPackages, invalidatePointsCache } from '../lib/api';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -50,49 +49,35 @@ function SidePanel({ s }: { s: Props['store'] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StorePage({ store: s, pricing: p }: Props) {
|
export default function StorePage({ store: s, pricing: p }: Props) {
|
||||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
const pointsState = usePoints();
|
||||||
const [packages, setPackages] = useState<PackageDisplay[]>([]);
|
const packagesState = usePackages();
|
||||||
const [loading, setLoading] = useState(true);
|
const points = pointsState.data ?? null;
|
||||||
|
const packages = useMemo<PackageDisplay[]>(() => {
|
||||||
useEffect(() => {
|
const packagesData = packagesState.data;
|
||||||
setLoading(true);
|
if (!packagesData) return [];
|
||||||
Promise.all([
|
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
|
||||||
getPointsBalance(),
|
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
|
||||||
getPackages(),
|
return {
|
||||||
])
|
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
|
||||||
.then(([pointsData, packagesData]) => {
|
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
|
||||||
setPoints(pointsData);
|
price: p[`${key}Price` as keyof typeof p] || '',
|
||||||
// Map backend packages to display format
|
credits: `${pkg.credits} ${s.pointsLabel}`,
|
||||||
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
|
desc: p[`${key}Desc` as keyof typeof p] || '',
|
||||||
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
|
featured: pkg.productCode === 'popular_pack',
|
||||||
return {
|
productCode: pkg.productCode,
|
||||||
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
|
appStoreProductId: pkg.appStoreProductId,
|
||||||
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
|
starterEligible: pkg.starterEligible,
|
||||||
price: p[`${key}Price` as keyof typeof p] || '',
|
isStarter: pkg.isStarter,
|
||||||
credits: `${pkg.credits} ${s.pointsLabel}`,
|
};
|
||||||
desc: p[`${key}Desc` as keyof typeof p] || '',
|
});
|
||||||
featured: pkg.productCode === 'popular_pack', // 只有常用加量包是推荐
|
displayPkgs.sort((a, b) => {
|
||||||
productCode: pkg.productCode,
|
const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode);
|
||||||
appStoreProductId: pkg.appStoreProductId,
|
const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode);
|
||||||
starterEligible: pkg.starterEligible,
|
return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0);
|
||||||
isStarter: pkg.isStarter,
|
});
|
||||||
};
|
return displayPkgs;
|
||||||
});
|
}, [packagesState.data, p, s.pointsLabel]);
|
||||||
// Sort by sortOrder
|
const loading = pointsState.loading || packagesState.loading;
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 min-h-full">
|
<div className="flex flex-col gap-5 min-h-full">
|
||||||
|
|||||||
+3
-14
@@ -139,23 +139,12 @@ export interface PackagesResponse {
|
|||||||
packages: PackageInfo[];
|
packages: PackageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Points cache with TTL
|
export function getPointsBalance(): Promise<PointsBalance> {
|
||||||
let pointsCache: { data: PointsBalance; expiry: number } | null = null;
|
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||||
const POINTS_CACHE_TTL = 60 * 1000; // 1 minute
|
|
||||||
|
|
||||||
export function getPointsBalance(useCache = true): Promise<PointsBalance> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (useCache && pointsCache && pointsCache.expiry > now) {
|
|
||||||
return Promise.resolve(pointsCache.data);
|
|
||||||
}
|
|
||||||
return authFetch<PointsBalance>(API_ROUTES.points.balance).then((data) => {
|
|
||||||
pointsCache = { data, expiry: now + POINTS_CACHE_TTL };
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidatePointsCache(): void {
|
export function invalidatePointsCache(): void {
|
||||||
pointsCache = null;
|
// Points caching lives in resources.ts. Kept for older imports during rollout.
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPackages(): Promise<PackagesResponse> {
|
export function getPackages(): Promise<PackagesResponse> {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client';
|
import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client';
|
||||||
import { API_ROUTES } from './api-routes';
|
import { API_ROUTES } from './api-routes';
|
||||||
|
import { clearAll as clearDataCache } from './data-client';
|
||||||
|
|
||||||
const STORAGE_KEY = 'meeyao_auth';
|
const STORAGE_KEY = 'meeyao_auth';
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export function setAuth(data: AuthData): void {
|
|||||||
|
|
||||||
export function clearAuth(): void {
|
export function clearAuth(): void {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
clearDataCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Token status ---
|
// --- Token status ---
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
export type CacheKey = readonly string[];
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data?: T;
|
||||||
|
error?: unknown;
|
||||||
|
updatedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
promise?: Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryOptions<T> {
|
||||||
|
key: CacheKey;
|
||||||
|
ttlMs: number;
|
||||||
|
fetcher: () => Promise<T>;
|
||||||
|
staleWhileRevalidate?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry<unknown>>();
|
||||||
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
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<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = cache.get(serializedKey) as CacheEntry<T> | 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<T>({
|
||||||
|
key,
|
||||||
|
ttlMs,
|
||||||
|
fetcher,
|
||||||
|
staleWhileRevalidate = true,
|
||||||
|
force = false,
|
||||||
|
}: QueryOptions<T>): Promise<T> {
|
||||||
|
const serializedKey = keyToString(key);
|
||||||
|
const entry = cache.get(serializedKey) as CacheEntry<T> | 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<T>(options: QueryOptions<T>): void {
|
||||||
|
void query(options).catch((error) => {
|
||||||
|
console.debug('[data-client] Prefetch failed', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peek<T>(key: CacheKey): T | undefined {
|
||||||
|
return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined {
|
||||||
|
return cache.get(keyToString(key)) as CacheEntry<T> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set<T>(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<Listener>();
|
||||||
|
keyListeners.add(listener);
|
||||||
|
listeners.set(serializedKey, keyListeners);
|
||||||
|
return () => {
|
||||||
|
keyListeners.delete(listener);
|
||||||
|
if (keyListeners.size === 0) listeners.delete(serializedKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
data: T | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
refreshing: boolean;
|
||||||
|
error: unknown;
|
||||||
|
reload: () => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceOptions<T> = QueryOptions<T> & {
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useResource<T>(options: ResourceOptions<T>): ResourceState<T> {
|
||||||
|
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<T | undefined>(() => enabled ? peek<T>(options.key) : undefined);
|
||||||
|
const [loading, setLoading] = useState(() => enabled && peek<T>(options.key) === undefined);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(() => enabled ? getEntry<T>(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<T>(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<T>(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<T>(optionsRef.current.key));
|
||||||
|
setError(getEntry<T>(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<UserProfile> {
|
||||||
|
return query({
|
||||||
|
key: profileKey,
|
||||||
|
ttlMs: PROFILE_TTL,
|
||||||
|
fetcher: getUserProfile,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfileResource(): Promise<UserProfile> {
|
||||||
|
return fetchProfileResource(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfile(): ResourceState<UserProfile> {
|
||||||
|
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<UserProfile> {
|
||||||
|
const updated = await updateUserProfile(input);
|
||||||
|
setProfileResource(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatarResource(file: File): Promise<UserProfile> {
|
||||||
|
const updated = await uploadAvatar(file);
|
||||||
|
setProfileResource(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettingsResource(settings: ProfileSettings): Promise<UserProfile> {
|
||||||
|
const updated = await updateUserSettings({ settings });
|
||||||
|
setProfileResource(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPointsResource(force = false): Promise<PointsBalance> {
|
||||||
|
return query({
|
||||||
|
key: pointsBalanceKey,
|
||||||
|
ttlMs: POINTS_TTL,
|
||||||
|
fetcher: getPointsBalance,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoints(): ResourceState<PointsBalance> {
|
||||||
|
return useResource({
|
||||||
|
key: pointsBalanceKey,
|
||||||
|
ttlMs: POINTS_TTL,
|
||||||
|
fetcher: getPointsBalance,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidatePoints(): void {
|
||||||
|
invalidate(pointsBalanceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackagesResource(force = false): Promise<PackagesResponse> {
|
||||||
|
return query({
|
||||||
|
key: packagesKey,
|
||||||
|
ttlMs: PACKAGES_TTL,
|
||||||
|
fetcher: getPackages,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePackages(): ResourceState<PackagesResponse> {
|
||||||
|
return useResource({
|
||||||
|
key: packagesKey,
|
||||||
|
ttlMs: PACKAGES_TTL,
|
||||||
|
fetcher: getPackages,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryListResource(force = false): Promise<HistorySnapshot> {
|
||||||
|
return query({
|
||||||
|
key: historyListKey,
|
||||||
|
ttlMs: HISTORY_TTL,
|
||||||
|
fetcher: getAgentHistory,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistoryList(): ResourceState<HistorySnapshot> {
|
||||||
|
return useResource({
|
||||||
|
key: historyListKey,
|
||||||
|
ttlMs: HISTORY_TTL,
|
||||||
|
fetcher: getAgentHistory,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistorySummaryResource(force = false): Promise<HistorySnapshot> {
|
||||||
|
return query({
|
||||||
|
key: historySummaryKey,
|
||||||
|
ttlMs: HISTORY_TTL,
|
||||||
|
fetcher: getAgentHistory,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistorySummary(): ResourceState<HistorySnapshot> {
|
||||||
|
return useResource({
|
||||||
|
key: historySummaryKey,
|
||||||
|
ttlMs: HISTORY_TTL,
|
||||||
|
fetcher: getAgentHistory,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryThreadResource(threadId: string, force = false): Promise<HistorySnapshot> {
|
||||||
|
return query({
|
||||||
|
key: historyThreadKey(threadId),
|
||||||
|
ttlMs: HISTORY_THREAD_TTL,
|
||||||
|
fetcher: () => getAgentHistoryByThread(threadId),
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistoryThread(threadId?: string): ResourceState<HistorySnapshot> {
|
||||||
|
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<NotificationListResponse> {
|
||||||
|
return query({
|
||||||
|
key: notificationsKey(locale),
|
||||||
|
ttlMs: NOTIFICATIONS_TTL,
|
||||||
|
fetcher: () => getNotifications(locale),
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications(locale: string): ResourceState<NotificationListResponse> {
|
||||||
|
return useResource({
|
||||||
|
key: notificationsKey(locale),
|
||||||
|
ttlMs: NOTIFICATIONS_TTL,
|
||||||
|
fetcher: () => getNotifications(locale),
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadCountResource(force = false): Promise<UnreadCount> {
|
||||||
|
return query({
|
||||||
|
key: unreadCountKey,
|
||||||
|
ttlMs: UNREAD_TTL,
|
||||||
|
fetcher: getUnreadNotificationCount,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadCount(): ResourceState<UnreadCount> {
|
||||||
|
return useResource({
|
||||||
|
key: unreadCountKey,
|
||||||
|
ttlMs: UNREAD_TTL,
|
||||||
|
fetcher: getUnreadNotificationCount,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationReadResource(id: string, locale: string): Promise<NotificationItem> {
|
||||||
|
const updated = await markNotificationRead(id, locale);
|
||||||
|
const listKey = notificationsKey(locale);
|
||||||
|
const list = peek<NotificationListResponse>(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<UnreadCount>(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<NotificationListResponse>(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<UserProfile>(profileKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedPackages(): PackageInfo[] | undefined {
|
||||||
|
return peek<PackagesResponse>(packagesKey)?.packages;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user