diff --git a/.env.example b/.env.example index f751e04c..fe80e0d0 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,20 @@ VITE_MAINNET_RPC_URL= # Stellar network for Stellar Expert explorer links. # Set to 'mainnet' for production, 'testnet' for development (default). VITE_STELLAR_NETWORK=testnet +VITE_STELLAR_RPC_URL= +# Horizon base URL used to poll transaction status after submission. Leave +# unset to use the public Horizon endpoint for VITE_STELLAR_NETWORK. +VITE_HORIZON_URL= +VITE_CREATOR_KEYS_CONTRACT_ID= + +# WebSocket endpoint for the real-time key-price feed (optional). Leave unset to +# disable the live feed — bonding-curve charts then rely on polled data only. +# VITE_WEBSOCKET_URL=wss://stream.example.com/keys + +# Comma-separated wallet addresses allowed to deploy new creator keys through the +# key factory wizard (optional). Leaving this unset fails closed and hides the wizard. +# The factory contract still authorizes the deployment server-side. +# VITE_KEY_FACTORY_WHITELIST= # UTM settings for shared profile links (optional) # Set any of these to enable UTM parameters on shared profile URLs. diff --git a/.freebuff/project-id b/.freebuff/project-id new file mode 100644 index 00000000..f45c4f4c --- /dev/null +++ b/.freebuff/project-id @@ -0,0 +1 @@ +23786e79-6f9d-4ab3-90d8-f57510f217b4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30e1970e..82e5c1c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,9 @@ jobs: uses: actions/checkout@v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.6.5 + run: | + corepack enable + corepack prepare pnpm@10.6.5 --activate - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 18af621f..5803a10b 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -1,29 +1,29 @@ name: Redirect PRs to dev on: - pull_request_target: - branches: - - main + pull_request_target: + branches: + - main jobs: - close-and-redirect: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Close PR and redirect to dev - uses: actions/github-script@v7 - with: - script: | - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: "👋 Thanks for your contribution! We don't accept pull requests directly to `main`. Please re-open your PR targeting the `dev` branch instead. See our contributing guide for details." - }); - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - state: 'closed' - }); + close-and-redirect: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Close PR and redirect to dev + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: "👋 Thanks for your contribution! We don't accept pull requests directly to `main`. Please re-open your PR targeting the `dev` branch instead. See our contributing guide for details." + }); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed' + }); diff --git a/.gitignore b/.gitignore index 32ceb457..6ef19594 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ dist-ssr issue.md pr.md +# MiMoCode +.mimocode/ # Test artifacts __snapshots__ *.snap @@ -39,4 +41,4 @@ Thumbs.db # Lock files from other package managers package-lock.json -yarn.lock \ No newline at end of file +yarn.lock diff --git a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md index 1d39f512..7f269be3 100644 --- a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md +++ b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md @@ -42,9 +42,9 @@ The production diff is intentionally small: one utility file, one hook, one comp - Verify `LandingPage.tsx` still compiles and the keyboard test (`LandingPage.keyboard.test.tsx`) still passes - _Requirements: 1.1, 3.4_ -- [ ] 5. Write the integration test +- [x] 5. Write the integration test - Create `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` - - [ ] 5.1 Set up test scaffolding + - [x] 5.1 Set up test scaffolding - Import `QueryClient`, `QueryClientProvider` from `@tanstack/react-query`; `MemoryRouter` from `react-router`; `render`, `screen`, `waitFor`, `act` from `@testing-library/react`; `fc` from `fast-check`; `beforeEach`, `afterEach`, `describe`, `expect`, `it`, `vi` from `vitest` - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` @@ -56,42 +56,42 @@ The production diff is intentionally small: one utility file, one hook, one comp - Implement `createWrapper(queryClient)` returning a component that wraps children in `` + `` - _Requirements: 4.1, 4.2, 4.3, 4.4_ - - [ ] 5.2 Write property test for Property 1 — initial render round-trip + - [x] 5.2 Write property test for Property 1 — initial render round-trip - **Property 1: Initial render round-trip** - **Validates: Requirements 1.1, 5.4** - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` - For each `count`: create fresh `queryClient`, seed with `queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count)`, render `FeaturedCreatorAudienceChip` with wrapper, assert `screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)` is in the document, assert `mockFetchHolderCount` was NOT called, then `unmount()` - _Requirements: 1.1, 1.2, 5.4_ - - [ ] 5.3 Write property test for Property 2 — stale-while-revalidate display stability + - [x] 5.3 Write property test for Property 2 — stale-while-revalidate display stability - **Property 2: Stale-while-revalidate display stability** - **Validates: Requirements 2.3** - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` - For each `initialCount`: seed cache, render component, call `queryClient.invalidateQueries` but do NOT resolve the pending `mockFetchHolderCount` (use a `Promise` that never resolves during the assertion window), assert old value is still visible and no blank/error state - _Requirements: 2.3_ - - [ ] 5.4 Write property test for Property 3 — post-invalidation update round-trip + - [x] 5.4 Write property test for Property 3 — post-invalidation update round-trip - **Property 3: Post-invalidation update round-trip** - **Validates: Requirements 3.1, 3.2, 3.4** - Use `fc.asyncProperty(fc.integer({ min: 1, max: 999 }), fc.integer({ min: 1000, max: 1_000_000 }), ...)` with `numRuns: 100` (disjoint ranges guarantee `initialCount !== updatedCount`) - For each pair `(initialCount, updatedCount)`: seed cache with `initialCount`, render, spy on `window.location.reload`, invalidate query, await `waitFor` assertion that updated text is visible and old text is gone, assert `reloadSpy` was NOT called, `unmount()` - _Requirements: 3.1, 3.2, 3.3, 3.4_ - - [ ] 5.5 Write property test for Property 4 — format function round-trip + - [x] 5.5 Write property test for Property 4 — format function round-trip - **Property 4: Format function round-trip** - **Validates: Requirements 5.1, 5.4** - Use synchronous `fc.property(fc.integer({ min: 1, max: 10_000_000 }), ...)` with `numRuns: 200` - For each `n > 0`: assert `getFeaturedCreatorKeyHolderCopy(n).value === formatCompactNumber(n) + ' key holders'` - _Requirements: 5.1, 5.4_ - - [ ]\* 5.6 Write edge-case tests + - [x]\* 5.6 Write edge-case tests - `count = 0` renders `"No key holders yet"` — seed cache with `0`, render, assert text present - `count = null` renders `"Key holders unavailable"` — seed cache with `null`, render, assert text present - Non-matching query key: invalidate a different key, assert `mockFetchHolderCount` was NOT called and display is unchanged - After invalidation + resolved refetch: assert `mockFetchHolderCount` was called exactly once with `CREATOR_ID` - _Requirements: 1.3, 1.4, 2.2, 2.4_ -- [ ] 6. Checkpoint — run tests and confirm everything passes +- [x] 6. Checkpoint — run tests and confirm everything passes - Run `pnpm test` (or `pnpm vitest run`) from `accesslayer-client--fork/` - Confirm `holderCountCacheInvalidation.test.tsx` passes all property and edge-case tests - Confirm `LandingPage.keyboard.test.tsx` still passes (no regression from Task 4 changes) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d41985d..6563b33b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,3 +145,4 @@ Issues labeled `good first issue` should: ## Questions If something is unclear, open a documentation issue or ask in the repo before starting implementation. +.. diff --git a/docs/CurveMigrationPanel.md b/docs/CurveMigrationPanel.md new file mode 100644 index 00000000..295bc649 --- /dev/null +++ b/docs/CurveMigrationPanel.md @@ -0,0 +1,75 @@ +# Curve Migration Panel + +Creator-only management surface for the bonding-curve migrations queued against +a creator key. It lives in the **Governance** tab of the creator dashboard +(`src/pages/CreatorDashboardPage.tsx`). + +- Component: `CurveMigrationPanel` +- File: `src/components/common/CurveMigrationPanel.tsx` +- Hook: `useCurveMigrations` — `src/hooks/useCurveMigrations.ts` +- Service: `curveMigrationService` — `src/services/curveMigration.service.ts` +- Types: `src/types/curveMigration.ts` +- Pure helpers: `src/utils/curveMigration.utils.ts` +- Execute mutation: `useExecuteCurveMigrationMutation` — + `src/hooks/useCreatorContractActions.ts` + +## Why + +A curve migration changes the price every holder buys and sells at, so it is +governed rather than applied directly. Two **independent** gates must both be +satisfied before the creator can execute one: + +1. **Vote approval** — quorum reached _and_ more weight `for` than `against`. +2. **Timelock elapsed** — the post-vote delay has passed, giving holders a + window to exit before the new pricing goes live. + +Either gate being unmet keeps Execute disabled, and the panel shows which one is +still blocking. + +## What the panel renders + +| Region | Test id | Contents | +| --------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| Pending list | `curve-migration-pending-section` | One card per `pending` migration, soonest timelock first | +| Proposed params | `curve-migration-params-{id}` | `from → to` diff of base price, growth factor, and graduated curve tiers; unchanged params carry `data-changed="false"` | +| Timelock | `curve-migration-timelock-{id}` | Live `HH:MM:SS` countdown, or `Ready` once elapsed | +| Vote approval | `curve-migration-vote-approval-{id}` | Share of cast weight that voted `for` | +| Vote status | `curve-migration-vote-status-{id}` | `Approved` / `Not approved`, with quorum and participation below | +| Execute | `curve-migration-execute-{id}` | Enabled only when both gates pass | +| History | `curve-migration-history-section` | Executed migrations with applied params and execution date | + +The countdown only re-renders while a timelock is actually running. + +## Visibility + +`CreatorDashboardPage` gates the section on `isKeyCreator` — the connected +wallet must match the key's `instructorId`. For non-creators the section is not +rendered at all **and** the `useCurveMigrations` query stays disabled, so no +migration data is fetched for wallets that cannot act on it. + +## Execute call + +Executing submits `execute_curve_migration`, built by the pure +`buildCurveMigrationExecuteCall` helper so the exact call is assertable without +signing anything: + +```ts +buildCurveMigrationExecuteCall({ creatorId, migrationId }); +// { functionName: 'execute_curve_migration', args: { creatorId, migrationId } } +``` + +On success both cache layers are invalidated together — the 15s +`curve_migrations_{keyId}` entry and the +`queryKeys.creators.curveMigrations(creatorId)` query — so the migration moves +from the pending list into the history with the params it actually applied. + +## Local validation + +```bash +pnpm test src/utils/__tests__/curveMigration.utils.test.ts +pnpm test src/services/__tests__/curveMigration.service.test.ts +pnpm test src/hooks/__tests__/useCurveMigrations.test.tsx +pnpm test src/hooks/__tests__/useCreatorContractActions.curveMigration.test.tsx +pnpm test src/components/common/__tests__/CurveMigrationPanel.test.tsx +pnpm test src/pages/__tests__/CreatorDashboardPage.curveMigration.integration.test.tsx +``` diff --git a/docs/LpPositionManagement.md b/docs/LpPositionManagement.md new file mode 100644 index 00000000..33e29cee --- /dev/null +++ b/docs/LpPositionManagement.md @@ -0,0 +1,103 @@ +# LP Position Management (Portfolio → Liquidity) + +The **Liquidity** tab on `/profile` (and `/profile/:wallet`, read-only) lets a +liquidity provider see their active LP positions and manage them (#1030). + +## What it shows + +- **Total LP earnings** card: unclaimed rewards + rewards already claimed from + the active positions, plus a position count. +- One row per active position: pool / key name, contributed amount, pool + share %, accrued (unclaimed) rewards, and lock status. +- Per-position actions, rendered only when the connected Stellar signer owns + the positions: **Add liquidity**, **Claim rewards**, **Remove liquidity**. + +## Where the data comes from + +| Concern | Source | Code | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| Positions, rewards, lock | `GET /lp/positions?wallet=` (accesslayer-server #980) | `services/lpPositions.service.ts` | +| Pool size, APR estimate | `GET /lp/pool/:keyId` (accesslayer-server #980) | `services/lpPositions.service.ts` | +| Spendable XLM | Account ledger entry over Soroban RPC (`VITE_STELLAR_RPC_URL`) | `services/stellarAccount.service.ts` | +| Add / claim / remove | Creator Keys contract `add_liquidity` / `claim_lp_rewards` / `remove_liquidity` (accesslayer-contracts #1002, PR #1004) | `services/lpContract.service.ts` | + +Hooks live in `hooks/useLpPositions.ts`; pure math lives in +`utils/lpPositions.utils.ts`. + +### Expected API shapes + +The server endpoints are specified in accesslayer-server #980 but were not +merged when this UI was built. The client expects the following shape and +validates every field. Amounts are **stroops as integer strings** (i128 in +the contract); numbers are accepted only when they are safe integers. + +```jsonc +// GET /lp/positions?wallet=G... → APIResponse<{ positions: [...] }> (a bare array also works) +{ + "lpId": "7", // contract lp_id (u64) + "keyId": "C...", // contract key_id (Address) + "keyName": "Alpha Key", // optional, falls back to a shortened keyId + "creatorId": "alpha", // optional, enables the /creator/:id link + "contribution": "250000000", + "share": 2500, // optional, contract basis points + "poolTotalLiquidity": "1000000000", // optional, preferred for an exact share + "pendingRewards": "12345", // missing/invalid → shown as "Unavailable" + "claimedRewards": "100", // optional, lifetime claimed from this position + "unlocksAt": "2030-01-01T00:00:00Z", // optional; ISO, epoch s, or epoch ms +} + +// GET /lp/pool/:keyId → APIResponse<{ keyId, totalLiquidity, aprBps }> +``` + +Records missing `lpId`, `keyId`, or a valid `contribution` are dropped, as are +closed positions (`contribution = 0`, which is how the contract reports them). + +## Rules the UI follows + +- **Precision.** All amounts are `bigint` stroops end to end and are rendered + with 7 decimals via `formatXlm`, so tiny rewards never display as `0.00`. +- **Pool share** is `contribution / poolTotalLiquidity` in integer math, + floored to 0.01%. A non-zero share under 0.01% reads `<0.01%`. If the pool + total is missing, the contract's basis-point `share` is used instead. +- **Earnings total** de-duplicates by `lpId` and is withheld ("Unavailable") + when any position's rewards can't be read, rather than showing a partial sum. +- **Add liquidity** validates format, > 0, ≤ 7 decimals, and ≤ spendable XLM + (balance minus account reserve and selling liabilities). The mutation reads + the balance again right before signing, so a balance that dropped after the + modal opened is caught. +- **Reward rate preview** shows the API's APR estimate and the pool share the + deposit would receive using the contract formula + `amount / (total_liquidity + amount)`. No reward-rate formula is invented + client-side; if the API has no APR the preview says "Unavailable". +- **Claim** simulates `claim_lp_rewards` first. If the contract would pay out + zero, the wallet is never prompted. +- **Remove** is disabled while `unlocksAt` is in the future, with a live + countdown (`2d 14h 32m`). An unparseable `unlocksAt` blocks removal. Before + signing, the mutation refetches positions and re-checks the lock, and the + contract simulation has the final say. +- **Confirmation.** Success toasts (with tx hash and Stellar Expert link) + appear only after `getTransaction` reports `SUCCESS`. A transaction that + lands but fails on-chain surfaces as an error. +- **Refresh.** After a confirmed transaction the hooks invalidate + `queryKeys.lp.positions(wallet)`, `queryKeys.lp.pool(keyId)`, and + `queryKeys.wallet.xlmBalance(wallet)`. Positions also refetch every 30s and + when a lock countdown reaches zero. + +## Known limitations + +- **Lock period:** the LP reward contract in PR #1004 has no lock. `unlocksAt` + is honoured if the API provides it. Without it, removal is allowed and the + contract decides. +- **Asset:** the contract does not name the deposited token. The UI assumes + XLM (stroops, 7 decimals) like every other amount in the app. +- **Entry point:** "Add liquidity" is offered per existing pool. Opening a + first position in a new pool needs an entry point on the creator page, + which is outside this tab's scope. +- **Base reserve** is fixed at 0.5 XLM (`BASE_RESERVE_STROOPS`), the current + value on mainnet and testnet. + +## Configuration + +Requires `VITE_STELLAR_RPC_URL` and `VITE_CREATOR_KEYS_CONTRACT_ID` (see +`.env.example`). Without them, reads from the API still work but actions show +a clear "not configured" error. diff --git a/docs/adding-page-and-data-fetching.md b/docs/adding-page-and-data-fetching.md index 50b311c9..d1fbc642 100644 --- a/docs/adding-page-and-data-fetching.md +++ b/docs/adding-page-and-data-fetching.md @@ -165,12 +165,8 @@ export const queryKeys = { import type { GetYourParams } from '@/services/your.service'; export const queryKeys = { - creators: { - /* ... */ - }, - wallet: { - /* ... */ - }, + creators: {/* ... */}, + wallet: {/* ... */}, yourEntity: { all: ['yourEntity'] as const, list: (params?: GetYourParams) => diff --git a/docs/admin-treasury-api.md b/docs/admin-treasury-api.md new file mode 100644 index 00000000..26fd35a9 --- /dev/null +++ b/docs/admin-treasury-api.md @@ -0,0 +1,38 @@ +# Admin treasury API contract + +The treasury panel calls the authenticated backend API; this client repository does not contain the Soroban contract source or its ABI/spec, so it deliberately does not invent contract entrypoint names or simulate successful transactions. The backend must read the deployed contract/event index and execute/confirm the real distribution transaction. + +All routes use the existing `BaseApiService` credentials and return the standard `{ success, data, message }` envelope. + +| Method | Route | `data` | +| ------ | ------------------------------- | -------------------------------------------------------- | +| `GET` | `/admin/treasury` | `{ accumulatedFeesStroops: string, updatedAt?: string }` | +| `GET` | `/admin/treasury/distributions` | `TreasuryDistribution[]` | +| `GET` | `/admin/treasury/fees` | Recent `FeeCollected` records, newest first | +| `POST` | `/admin/treasury/distributions` | Confirmed `{ epoch: number, transactionHash: string }` | + +Distribution request body: + +```json +{ + "admin": "G...", + "totalAmountStroops": "10000000", + "recipients": [{ "address": "G...", "amountStroops": "10000000" }] +} +``` + +Every amount is a non-negative decimal integer in stroops (1 XLM = 10,000,000 stroops). The client validates the Stellar addresses, rejects duplicate recipients, and requires the allocation sum to equal the displayed treasury balance. The backend must independently authenticate/authorize the admin, re-read the contract balance, validate allocations, submit the contract call, wait for successful confirmation, and only then return the epoch and transaction hash. A response without those confirmation fields is treated as a failure. + +Distribution records use `{ id, epoch, totalDistributedStroops, recipients: [{ address, amountStroops }], distributedAt, transactionHash }`. Fee event records use `{ id, creatorAddress, traderAddress, amountStroops, collectedAt, transactionHash }`. + +## Protocol fee dashboard integration gap + +The current client/API contract is not sufficient to implement the protocol fee dashboard (#1153) without inventing data sources or contract calls: + +- `GET /admin/treasury` exposes one accumulated treasury amount only. It does not report separate accumulated and withdrawable amounts, LP-pool fees, or staking-pool fees. +- `POST /admin/treasury/distributions` distributes the treasury balance to admin-selected recipients. It is not a destination-specific withdrawal method, does not use the connected wallet's signer, and cannot safely substitute for treasury, LP-pool, or staking-pool withdrawals. The client has no corresponding contract spec or fee-withdrawal entrypoints to invoke directly. +- `GET /admin/treasury/fees` is documented as returning recent events, without a completeness or date-range guarantee. It cannot establish a complete 30-day daily history. Its event records contain creator and trader addresses, but no key identifier or cumulative per-key total, so they cannot provide the requested top fee-generating keys. + +The existing frontend admin check is the configured `VITE_ADMIN_WALLETS` allowlist (`src/utils/adminAccess.ts`); it is a UI gate, not contract authorization. The treasury API documentation requires the backend to independently authorize admin requests. A dashboard must continue to treat that server/contract authorization as authoritative for any transaction. + +Implementation requires a verified source of treasury, LP-pool, and staking-pool accumulated/withdrawable balances; destination-specific withdrawal contract methods or a documented connected-wallet signing/confirmation flow; a complete indexed fee-event query for a 30-day window with a defined timezone; and indexed per-key cumulative fee totals with stable key identifiers. Until those interfaces and their deployed contract specifications are available, the client must not display inferred balances or historical totals, or submit guessed withdrawal calls. diff --git a/docs/error-handling-conventions.md b/docs/error-handling-conventions.md index e8859e9a..92997115 100644 --- a/docs/error-handling-conventions.md +++ b/docs/error-handling-conventions.md @@ -35,6 +35,14 @@ to be able to do. | --- | ------------------ | ----------------------------- | ----------------- | -------------------------------------------------- | | 6 | **Network banner** | Connected wallet, wrong chain | Persistent notice | `NetworkMismatchBanner` from `@/components/common` | +For route-level runtime failures, wrap major routes with `RouteErrorBoundary`; +for independent widgets within a page, use `SectionErrorBoundary`. Both provide +a contextual fallback, retry by remounting the failed subtree, and a support +link. Caught exception details and diagnostics are development-only and are +not rendered or logged in production builds. These boundaries handle React +render/lifecycle errors; query and mutation failures should continue to use +the inline and toast patterns below. + --- ## Decision Flowchart diff --git a/docs/react-query-cache-conventions.md b/docs/react-query-cache-conventions.md index c4aa4cd8..564adf0e 100644 --- a/docs/react-query-cache-conventions.md +++ b/docs/react-query-cache-conventions.md @@ -67,8 +67,8 @@ same patterns: import type { GetCoursesParams } from '@/services/course.service'; export const queryKeys = { - creators: { /* … */ }, - wallet: { /* … */ }, + creators: {/* … */}, + wallet: {/* … */}, courses: { all: ['courses'] as const, list: (params?: GetCoursesParams) => @@ -101,9 +101,7 @@ shapes and shared prefixes: ```ts it('courses.detail shares the courses prefix with courses.all', () => { - expect(queryKeys.courses.detail('x')[0]).toBe( - queryKeys.courses.all[0], - ); + expect(queryKeys.courses.detail('x')[0]).toBe(queryKeys.courses.all[0]); }); it('courses.detail embeds the id at index 2', () => { @@ -157,15 +155,12 @@ export function useUpdateCourseTitle() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - courseId, - title, - }: { courseId: string; title: string }) => + mutationFn: ({ courseId, title }: { courseId: string; title: string }) => courseService.updateTitle(courseId, title), onSuccess: (updatedCourse, { courseId }) => { queryClient.setQueryData( queryKeys.courses.detail(courseId), - updatedCourse, + updatedCourse ); }, }); @@ -180,13 +175,13 @@ Use `setQueryData` when: ### Decision Table -| Situation | Approach | -|---|---| -| Mutation changes server state, response is minimal | `invalidateQueries` | -| Mutation response includes full updated object | `setQueryData` | -| Optimistic update with rollback | `setQueryData` + `onError` rollback | -| Multiple entities affected by one mutation | `invalidateQueries` on shared prefix | -| User clicks "Refresh" button | `refetch()` on the specific query | +| Situation | Approach | +| -------------------------------------------------- | ------------------------------------ | +| Mutation changes server state, response is minimal | `invalidateQueries` | +| Mutation response includes full updated object | `setQueryData` | +| Optimistic update with rollback | `setQueryData` + `onError` rollback | +| Multiple entities affected by one mutation | `invalidateQueries` on shared prefix | +| User clicks "Refresh" button | `refetch()` on the specific query | See [docs/state-management.md](./state-management.md) for the general rule on when data belongs in React Query vs local state. @@ -199,10 +194,10 @@ when data belongs in React Query vs local state. The client does not set global overrides, so React Query v5 defaults apply: -| Option | Default | Meaning | -|---|---|---| -| `staleTime` | `0` | Data is stale immediately. Queries refetch on mount, window focus, and reconnect. | -| `gcTime` | `5 * 60 * 1000` (5 minutes) | Unused/inactive data stays in the cache for 5 minutes before garbage collection. | +| Option | Default | Meaning | +| ----------- | --------------------------- | --------------------------------------------------------------------------------- | +| `staleTime` | `0` | Data is stale immediately. Queries refetch on mount, window focus, and reconnect. | +| `gcTime` | `5 * 60 * 1000` (5 minutes) | Unused/inactive data stays in the cache for 5 minutes before garbage collection. | ### When to Override @@ -218,12 +213,12 @@ useQuery({ }); ``` -| Scenario | Recommended `staleTime` | Rationale | -|---|---|---| -| Real-time or live data (prices, balances) | `0` (default) | Always show the latest value. | -| Semi-static data (profile details, course metadata) | `30_000` – `60_000` (30–60 s) | Balances freshness against unnecessary refetches. | -| Rarely-changing data (creator list, static config) | `5 * 60_000` (5 min) or longer | Reduce bandwidth for data that barely changes. | -| Data that never changes during a session | `Infinity` | Fetch once; never refetch until the page reloads. | +| Scenario | Recommended `staleTime` | Rationale | +| --------------------------------------------------- | ------------------------------ | ------------------------------------------------- | +| Real-time or live data (prices, balances) | `0` (default) | Always show the latest value. | +| Semi-static data (profile details, course metadata) | `30_000` – `60_000` (30–60 s) | Balances freshness against unnecessary refetches. | +| Rarely-changing data (creator list, static config) | `5 * 60_000` (5 min) or longer | Reduce bandwidth for data that barely changes. | +| Data that never changes during a session | `Infinity` | Fetch once; never refetch until the page reloads. | Override `gcTime` only when you want to keep data in the cache longer (or shorter) than the 5 minute default — for example, to preserve form draft data diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md index a44bf63c..af5858b3 100644 --- a/docs/testing-conventions.md +++ b/docs/testing-conventions.md @@ -94,7 +94,15 @@ tests mock at that seam: vi.mock('@/hooks/useWallet', () => ({ // "connected wallet holding 2 keys of creator-a" useWalletHoldings: () => ({ - data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }], + data: [ + { + creatorId: 'creator-a', + quantity: 2, + priceStroops: 500_000, + price: 0.05, + pending: false, + }, + ], }), useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), })); @@ -139,14 +147,14 @@ The standard shell for a page-level integration test: There is deliberately no shared custom `render` yet; each suite composes its own providers. The reusable pieces to copy today: -| Utility | Where | What it does | -|---|---|---| -| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers | -| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom | -| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub | -| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled | -| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm | -| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event | +| Utility | Where | What it does | +| ---------------------------- | ------------------------------------------------------------ | --------------------------------------------------- | +| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers | +| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom | +| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub | +| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled | +| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm | +| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event | If you find yourself copying more than two of these into a new file, that is the signal to promote them into `src/test/` as shared utilities — do it in diff --git a/index.html b/index.html index a5734ba4..5e7a099a 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,21 @@ + + +