diff --git a/backend/src/__tests__/timeoutMiddleware.test.ts b/backend/src/__tests__/timeoutMiddleware.test.ts new file mode 100644 index 00000000..4eb0a552 --- /dev/null +++ b/backend/src/__tests__/timeoutMiddleware.test.ts @@ -0,0 +1,52 @@ +import express, { Request, Response } from 'express'; +import request from 'supertest'; +import { timeoutMiddleware, createTimeoutFor } from '../middleware/timeoutMiddleware'; + +describe('timeoutMiddleware', () => { + it('returns a structured fallback payload when the route exceeds its budget', async () => { + const app = express(); + app.get( + '/slow', + createTimeoutFor.read({ + timeoutMs: 20, + routeName: '/slow', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'SLOW_ROUTE_TIMEOUT', + message: 'Slow route timed out', + stale: true, + }), + }), + async (_req: Request, res: Response) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + res.status(200).json({ ok: true }); + }, + ); + + const res = await request(app).get('/slow'); + expect(res.status).toBe(503); + expect(res.body).toMatchObject({ + error: 'Service Unavailable', + status: 503, + code: 'SLOW_ROUTE_TIMEOUT', + message: 'Slow route timed out', + stale: true, + }); + }); + + it('allows fast requests to complete normally', async () => { + const app = express(); + app.get( + '/fast', + timeoutMiddleware({ timeoutMs: 50 }), + (_req: Request, res: Response) => { + res.status(200).json({ ok: true }); + }, + ); + + const res = await request(app).get('/fast'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 6683bb39..4cf3628d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -865,6 +865,20 @@ app.get( '/api/v1/vault/summary', readsLimiter, cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vault/summary', + message: 'Vault summary took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_SUMMARY_TIMEOUT', + message: 'Vault summary is temporarily unavailable. Please refresh in a moment.', + stale: true, + data: buildVaultSummaryResponse(), + timestamp: new Date().toISOString(), + }), + }), (_req: Request, res: Response) => { res.json(buildVaultSummaryResponse()); }, @@ -876,6 +890,19 @@ app.get( app.get( '/api/v1/vault/metrics', cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 2000, + routeName: '/api/v1/vault/metrics', + message: 'Vault metrics took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_METRICS_TIMEOUT', + message: 'Vault metrics are temporarily unavailable. Showing the last known state.', + stale: true, + timestamp: new Date().toISOString(), + }), + }), (_req: Request, res: Response) => { res.json({ message: 'Vault metrics', @@ -890,6 +917,19 @@ app.get( app.get( '/api/v1/vault/apy', cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1200, + routeName: '/api/v1/vault/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + stale: true, + timestamp: new Date().toISOString(), + }), + }), (_req: Request, res: Response) => { res.json({ message: 'Vault APY', diff --git a/backend/src/middleware/timeoutMiddleware.ts b/backend/src/middleware/timeoutMiddleware.ts index 45781c44..cc66af28 100644 --- a/backend/src/middleware/timeoutMiddleware.ts +++ b/backend/src/middleware/timeoutMiddleware.ts @@ -11,6 +11,10 @@ export interface TimeoutOptions { fallbackResponse?: (req: Request) => Record; /** Optional: custom message for the timeout */ message?: string; + /** Optional HTTP status for the fallback response */ + fallbackStatusCode?: number; + /** Optional route label used in structured fallback payloads */ + routeName?: string; } /** @@ -19,6 +23,7 @@ export interface TimeoutOptions { export const DEFAULT_TIMEOUT_OPTIONS: TimeoutOptions = { timeoutMs: 30000, // 30 seconds default message: 'Request timed out', + fallbackStatusCode: 408, }; /** @@ -57,12 +62,14 @@ export function timeoutMiddleware(options: TimeoutOptions = DEFAULT_TIMEOUT_OPTI const response = fallbackResponse ? fallbackResponse(req) : { error: 'Request Timeout', status: 408, + code: 'TIMEOUT', message: message || DEFAULT_TIMEOUT_OPTIONS.message, retryAfter: Math.ceil(timeoutMs / 1000), + route: routeName ?? req.originalUrl ?? req.path, timestamp: new Date().toISOString(), }; - res.status(408).json(response); + res.status(fallbackStatusCode ?? 408).json(response); cleanup(); } }, timeoutMs); @@ -102,11 +109,31 @@ export function timeoutMiddleware(options: TimeoutOptions = DEFAULT_TIMEOUT_OPTI */ export const createTimeoutFor = { /** Read‑heavy endpoints (5 seconds) */ - read: () => timeoutMiddleware({ timeoutMs: 5000 }), + read: (options: Partial = {}) => + timeoutMiddleware({ + timeoutMs: 5000, + fallbackStatusCode: 503, + ...options, + }), /** Write operations (15 seconds) */ - write: () => timeoutMiddleware({ timeoutMs: 15000 }), + write: (options: Partial = {}) => + timeoutMiddleware({ + timeoutMs: 15000, + fallbackStatusCode: 503, + ...options, + }), /** Admin operations (30 seconds) */ - admin: () => timeoutMiddleware({ timeoutMs: 30000 }), + admin: (options: Partial = {}) => + timeoutMiddleware({ + timeoutMs: 30000, + fallbackStatusCode: 503, + ...options, + }), /** Export/download endpoints (60 seconds) */ - export: () => timeoutMiddleware({ timeoutMs: 60000 }), + export: (options: Partial = {}) => + timeoutMiddleware({ + timeoutMs: 60000, + fallbackStatusCode: 503, + ...options, + }), }; diff --git a/frontend/src/components/APYTrendChart.tsx b/frontend/src/components/APYTrendChart.tsx index 7aea9aa2..581246b6 100644 --- a/frontend/src/components/APYTrendChart.tsx +++ b/frontend/src/components/APYTrendChart.tsx @@ -22,6 +22,8 @@ import { usePolling } from "../hooks/usePolling"; import { useStaleIndicator } from "../hooks/useStaleIndicator"; import ChartWidgetPlaceholder from "./ui/ChartWidgetPlaceholder"; import { ChartModeToggle } from "./ChartModeToggle"; +import Badge from "./Badge"; +import { Clock3 } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -303,6 +305,16 @@ const APYTrendChart: React.FC = ({ data = ALL_HISTORY }) =>

Comparative APY across selectable time windows

+
+ } + > + {isStale ? `Stale ${ageText || ""}`.trim() : ageText ? `Fresh ${ageText}` : "Live"} + +
{/* Primary range selector */} diff --git a/frontend/src/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index 7cf5d605..5349c443 100644 --- a/frontend/src/components/VaultDashboard.test.tsx +++ b/frontend/src/components/VaultDashboard.test.tsx @@ -14,6 +14,11 @@ import * as tokenAllowanceHooks from "../hooks/useTokenAllowance"; import * as vaultMutations from "../hooks/useVaultMutations"; import type { UseQueryResult } from "@tanstack/react-query"; import type { PortfolioHolding } from "../lib/portfolioApi"; +import confetti from "canvas-confetti"; + +vi.mock("canvas-confetti", () => ({ + default: vi.fn(), +})); vi.mock("../lib/vaultApi", async (importOriginal) => { const actual = await importOriginal(); @@ -171,6 +176,8 @@ describe("VaultDashboard", () => { approve: vi.fn().mockResolvedValue(undefined), resetApproval: vi.fn(), }); + window.matchMedia = vi.fn().mockReturnValue({ matches: false } as MediaQueryList); + localStorage.clear(); }); afterEach(() => { @@ -256,6 +263,28 @@ describe("VaultDashboard", () => { }, { timeout: 10000 }); }, 15000); + it("plays the first deposit confetti once per wallet", async () => { + const mutateAsync = vi.fn().mockResolvedValue({}); + vi.mocked(vaultMutations.useDepositMutation).mockReturnValue({ + mutateAsync, + isPending: false, + } as unknown as ReturnType); + + renderDashboard("GFIRSTDEPOSITWALLET000000000000000000000000000000"); + + const input = await screen.findByPlaceholderText("0.00"); + fireEvent.change(input, { target: { value: "100" } }); + fireEvent.click(screen.getByRole("button", { name: "Review Transaction" })); + fireEvent.click(await screen.findByRole("button", { name: /Confirm deposit/i })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalled(); + }); + + expect(confetti).toHaveBeenCalled(); + expect(localStorage.getItem("yieldvault:first-deposit:GFIRSTDEPOSITWALLET000000000000000000000000000000")).toBe("true"); + }); + it("fills the input with max allowable amount via MAX button", async () => { renderDashboard("GABC123"); diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index ddeb882d..e516e1ac 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import { ArrowDownUp, ArrowUpRight, Clock3, Menu, X } from "lucide-react"; import { Activity, AlertCircle, @@ -28,6 +29,7 @@ import { createWithdrawFormSchema } from "../forms/schemas/withdrawFormSchema"; import { mapServerError } from "../lib/errorMappers"; import confetti from "canvas-confetti"; import CopyButton from "./CopyButton"; +import Badge from "./Badge"; import { Button } from "./ui/Button"; import { copyTextToClipboard } from "../lib/clipboard"; import { useFeeEstimate } from "../hooks/useFeeEstimate"; @@ -58,6 +60,37 @@ import { } from "../lib/transactionConflict"; import type { StaleFieldChange } from "../lib/staleSubmissionDetection"; +const FIRST_DEPOSIT_PREFIX = "yieldvault:first-deposit:"; + +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function runDepositConfetti(): void { + if (prefersReducedMotion()) { + return; + } + + const end = Date.now() + 2000; + const intervalId = window.setInterval(() => { + confetti({ + particleCount: 28, + spread: 72, + startVelocity: 34, + gravity: 1.05, + ticks: 90, + origin: { x: Math.random() * 0.6 + 0.2, y: 0.6 }, + colors: ["#00f0ff", "#a855f7", "#ffffff", "#3b82f6"], + }); + + if (Date.now() > end) { + window.clearInterval(intervalId); + } + }, 220); +} + /** * Visual indicator for the 3-step transaction wizard. * Shows progress through Amount, Review, and Result stages. @@ -193,6 +226,7 @@ const VaultDashboard: React.FC = ({ message: string; txHash?: string } | null>(null); + const [mobileActionsOpen, setMobileActionsOpen] = useState(false); const [activeConflict, setActiveConflict] = useState<{ conflict: TransactionConflictDetails; staleChanges?: StaleFieldChange[]; @@ -476,29 +510,15 @@ const VaultDashboard: React.FC = ({ await depositMutation.mutateAsync(mutationParams); try { - const depositKey = `has_deposited_${walletAddress}`; - const alreadyDeposited = localStorage.getItem(depositKey); - const isTest = typeof process !== "undefined" && process.env?.NODE_ENV === "test"; - if (!alreadyDeposited && !isTest) { - confetti({ - particleCount: 150, - spread: 80, - origin: { y: 0.6 }, - colors: ["#00f0ff", "#a855f7", "#ffffff", "#3b82f6"] - }); + const depositKey = `${FIRST_DEPOSIT_PREFIX}${walletAddress}`; + const alreadyCelebrated = localStorage.getItem(depositKey) === "true"; + if (!alreadyCelebrated) { localStorage.setItem(depositKey, "true"); + runDepositConfetti(); } } catch (storageErr) { - console.warn("Storage access failed, triggering confetti anyway", storageErr); - const isTest = typeof process !== "undefined" && process.env?.NODE_ENV === "test"; - if (!isTest) { - confetti({ - particleCount: 150, - spread: 80, - origin: { y: 0.6 }, - colors: ["#00f0ff", "#a855f7", "#ffffff", "#3b82f6"] - }); - } + console.warn("Storage access failed while tracking first deposit state", storageErr); + runDepositConfetti(); } } else { await withdrawMutation.mutateAsync(mutationParams); @@ -650,6 +670,15 @@ const VaultDashboard: React.FC = ({ variant="tooltip" content="Annualized yield based on the historical performance of the vault's underlying assets." /> + } + style={{ marginLeft: "4px", whiteSpace: "nowrap" }} + > + {statsIsStale ? `Stale ${statsAgeText || ""}`.trim() : statsAgeText ? `Fresh ${statsAgeText}` : "Live"} +
{delayedLoading ? : formattedApy} @@ -1185,6 +1214,16 @@ const VaultDashboard: React.FC = ({ {minReceived(estimatedNetAmount).toFixed(4)} USDC
+
+ } + > + Quote {statsIsStale ? `stale ${statsAgeText}`.trim() : statsAgeText ? `fresh ${statsAgeText}` : "fresh"} + +
)} @@ -1359,7 +1398,46 @@ const VaultDashboard: React.FC = ({ ))} +
+ +
+ {mobileActionsOpen && ( +
+ +
+
+ + + +
+ + + )} ); }; diff --git a/frontend/src/index.css b/frontend/src/index.css index a094d1fd..25397bc8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -810,6 +810,11 @@ button { transform: scale(0.98); } +.mobile-vault-actions, +.mobile-bottom-sheet { + display: none; +} + /* Animations */ @keyframes puiseGlow { 0% { box-shadow: 0 0 20px rgba(0, 240, 255, 0.1); } @@ -837,6 +842,65 @@ button { } } +@media (max-width: 768px) { + .mobile-vault-actions { + position: sticky; + bottom: 12px; + display: flex; + justify-content: center; + padding-top: 12px; + z-index: 25; + } + + .mobile-bottom-sheet { + position: fixed; + inset: 0; + display: block; + z-index: 140; + } + + .mobile-bottom-sheet__backdrop { + position: absolute; + inset: 0; + border: 0; + padding: 0; + background: rgba(0, 0, 0, 0.42); + } + + .mobile-bottom-sheet__panel { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 16px; + border-top-left-radius: 16px; + border-top-right-radius: 16px; + background: var(--bg-surface); + border: 1px solid var(--border-glass); + box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.28); + } + + .mobile-bottom-sheet__handle { + width: 44px; + height: 4px; + border-radius: 999px; + margin: 0 auto 12px; + background: var(--border-glass); + } + + .mobile-bottom-sheet__actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .mobile-bottom-sheet__actions .btn { + width: 100%; + padding: 12px 10px; + flex-direction: column; + } +} + /* Form Elements */ .input-group { display: flex;