From 68cbc5c5e9f58f6862d718e572c45fe2ff41ff54 Mon Sep 17 00:00:00 2001 From: Dev-Moulin Date: Mon, 16 Feb 2026 12:01:42 +0100 Subject: [PATCH 1/2] feat: optimize GraphQL with React Context data sharing + reduce TTL to 2min Reduce HTTP queries ~55% when opening founder panels by sharing homepage data via FoundersDataContext instead of each hook launching its own queries. - Enrich homepage queries (+11 fields on proposals, +4 on deposits) - Create FoundersDataContext + Provider wrapping useFoundersForHomePage - Migrate 6 hooks to read from Context (0 network queries) - Only tags + user positions still fetch from network - Reduce cache TTL from 5min to 2min for fresher data --- .../components/founder/FounderCenterPanel.tsx | 1 + .../src/components/modal/ClaimExistsModal.tsx | 4 +- apps/web/src/contexts/FoundersDataContext.tsx | 51 ++++++ .../src/hooks/data/useFounderPanelStats.ts | 158 +++++------------- .../web/src/hooks/data/useFounderProposals.ts | 47 ++---- .../src/hooks/data/useFoundersForHomePage.ts | 49 +++++- .../web/src/hooks/data/useTopTotemsByCurve.ts | 56 ++----- .../src/hooks/data/useUserVotesForFounder.ts | 55 ++---- apps/web/src/hooks/data/useVoteMarketStats.ts | 4 +- apps/web/src/hooks/data/useVotesTimeline.ts | 150 +++++------------ apps/web/src/lib/graphql/queries.ts | 16 ++ apps/web/src/lib/graphql/types.ts | 5 + apps/web/src/lib/queryCacheTTL.ts | 5 +- apps/web/src/pages/HomePage3DCarousel.tsx | 6 +- apps/web/src/router.tsx | 3 +- 15 files changed, 256 insertions(+), 354 deletions(-) create mode 100644 apps/web/src/contexts/FoundersDataContext.tsx diff --git a/apps/web/src/components/founder/FounderCenterPanel.tsx b/apps/web/src/components/founder/FounderCenterPanel.tsx index 05a1e2f..ec02b36 100644 --- a/apps/web/src/components/founder/FounderCenterPanel.tsx +++ b/apps/web/src/components/founder/FounderCenterPanel.tsx @@ -86,6 +86,7 @@ export function FounderCenterPanel({ }: FounderCenterPanelProps) { const { t } = useTranslation(); const { isConnected, address } = useAccount(); + const { proposals, loading: proposalsLoading, refetch: refetchProposals } = useFounderProposals(founder.name); const { totems: ofcTotems, loading: ofcLoading, dynamicCategories } = useAllOFCTotems(); const { votes: userVotes, loading: votesLoading, refetch: refetchVotes } = useUserVotesForFounder(address, founder.name); diff --git a/apps/web/src/components/modal/ClaimExistsModal.tsx b/apps/web/src/components/modal/ClaimExistsModal.tsx index 464c343..c5dfa65 100644 --- a/apps/web/src/components/modal/ClaimExistsModal.tsx +++ b/apps/web/src/components/modal/ClaimExistsModal.tsx @@ -10,7 +10,7 @@ import { GET_USER_POSITION } from '../../lib/graphql/queries'; import { WithdrawModal } from './WithdrawModal'; import type { ExistingClaimInfo } from '../../types/claim'; import { SUPPORT_COLORS, OPPOSE_COLORS } from '../../config/colors'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { getCacheFetchPolicy, TWO_MINUTES } from '../../lib/queryCacheTTL'; // Re-export for backward compatibility export type { ExistingClaimInfo } from '../../types/claim'; @@ -78,7 +78,7 @@ export function ClaimExistsModal({ const positionFetchPolicy = getCacheFetchPolicy( 'GetUserPosition', { walletAddress: address?.toLowerCase(), termId: claim?.termId?.toLowerCase() }, - FIVE_MINUTES + TWO_MINUTES ); // Also query GraphQL as fallback (for display purposes) diff --git a/apps/web/src/contexts/FoundersDataContext.tsx b/apps/web/src/contexts/FoundersDataContext.tsx new file mode 100644 index 0000000..e06f1f4 --- /dev/null +++ b/apps/web/src/contexts/FoundersDataContext.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { ApolloError } from '@apollo/client'; +import type { Triple, GetDepositsByTermIdsResult } from '../lib/graphql/types'; +import type { FounderForHomePage } from '../types/founder'; +import type { TopTotem } from '../hooks/data/useTopTotems'; +import { useFoundersForHomePage } from '../hooks/data/useFoundersForHomePage'; + +type DepositEntry = GetDepositsByTermIdsResult['deposits'][number]; + +interface FoundersDataContextValue { + // Enriched founders list (42 founders with atomId, winningTotem, etc.) + founders: FounderForHomePage[]; + // Homepage stats + stats: { + totalTrustVoted: number; + uniqueVoters: number; + foundersWithTotems: number; + totalProposals: number; + }; + // Pre-computed top totems per founder (for carousel cards) + topTotemsMap: Map; + // Raw proposals grouped by founder name (for panel hooks) + proposalsByFounder: Map; + // Raw deposits indexed by term_id (for panel hooks) + depositsByTermId: Map; + // State + loading: boolean; + error: ApolloError | null; + // Invalidation after vote + refetch: () => Promise; +} + +const FoundersDataContext = createContext(null); + +export function FoundersDataProvider({ children }: { children: ReactNode }) { + const data = useFoundersForHomePage(); + + return ( + + {children} + + ); +} + +export function useFoundersData(): FoundersDataContextValue { + const ctx = useContext(FoundersDataContext); + if (!ctx) { + throw new Error('useFoundersData must be used within a FoundersDataProvider'); + } + return ctx; +} diff --git a/apps/web/src/hooks/data/useFounderPanelStats.ts b/apps/web/src/hooks/data/useFounderPanelStats.ts index 50cac58..65a2998 100644 --- a/apps/web/src/hooks/data/useFounderPanelStats.ts +++ b/apps/web/src/hooks/data/useFounderPanelStats.ts @@ -13,20 +13,11 @@ * @see Phase 10 - Etape 6 in TODO_FIX_01_Discussion.md */ -import { useMemo, useCallback } from 'react'; -import { useQuery } from '@apollo/client'; +import { useMemo } from 'react'; import { formatEther } from 'viem'; -import { - GET_FOUNDER_PANEL_STATS, - GET_DEPOSITS_BY_TERM_IDS, -} from '../../lib/graphql/queries'; -import type { - GetFounderPanelStatsResult, - GetDepositsByTermIdsResult, -} from '../../lib/graphql/types'; import { truncateAmount } from '../../utils/formatters'; import { useAllOFCTotems } from './useAllOFCTotems'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { useFoundersData } from '../../contexts/FoundersDataContext'; export interface FounderPanelStats { /** Total Market Cap in wei */ @@ -83,126 +74,55 @@ function formatMarketCap(value: bigint): string { * ``` */ export function useFounderPanelStats(founderName: string): UseFounderPanelStatsReturn { - // Get OFC category map for filtering + const { proposalsByFounder, depositsByTermId, loading, error, refetch } = useFoundersData(); const { categoryMap, loading: categoryLoading } = useAllOFCTotems(); - // TTL-based cache policy: only fetch if data is older than 5 minutes - const triplesFetchPolicy = getCacheFetchPolicy( - 'GetFounderPanelStats', - { founderName }, - FIVE_MINUTES - ); + const stats = useMemo(() => { + const proposals = proposalsByFounder.get(founderName) ?? []; - // First query: get triples for this founder - const { - data: triplesData, - loading: triplesLoading, - error: triplesError, - refetch: refetchTriples, - } = useQuery(GET_FOUNDER_PANEL_STATS, { - variables: { founderName }, - skip: !founderName, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy: triplesFetchPolicy, - nextFetchPolicy: 'cache-first', - }); + // Filter to OFC totems only + const ofcTriples = proposals.filter((t) => + t.object?.term_id && categoryMap.has(t.object.term_id) + ); - // Filter triples to only include OFC totems - const filteredTriples = useMemo(() => { - if (!triplesData?.triples) return []; - return triplesData.triples.filter((t) => { - // Only keep triples where the totem (object) has an OFC category - return t.object?.term_id && categoryMap.has(t.object.term_id); - }); - }, [triplesData?.triples, categoryMap]); + // Total Market Cap = Σ(FOR + AGAINST) + let totalMarketCap = 0n; + const termIds = new Set(); - // Extract term_ids from filtered triples for the deposits query - // Include both term_id (FOR) and counter_term.id (AGAINST) - const termIds = useMemo(() => { - const ids: string[] = []; - filteredTriples.forEach((t) => { - ids.push(t.term_id); - if (t.counter_term?.id) { - ids.push(t.counter_term.id); + for (const triple of ofcTriples) { + if (triple.triple_vault?.total_assets) { + totalMarketCap += BigInt(triple.triple_vault.total_assets); + } + if (triple.counter_term?.total_assets) { + totalMarketCap += BigInt(triple.counter_term.total_assets); + } + termIds.add(triple.term_id); + if (triple.counter_term?.id) { + termIds.add(triple.counter_term.id); } - }); - return ids; - }, [filteredTriples]); - - // TTL-based cache policy for deposits query - // Use founderName as key since termIds is derived from founder's triples - const depositsFetchPolicy = getCacheFetchPolicy( - 'GetDepositsByTermIds_PanelStats', - { founderName }, - FIVE_MINUTES - ); - - // Second query: get deposits for those term_ids - // Apollo automatically refetches when termIds changes (via variables) - const { - data: depositsData, - loading: depositsLoading, - error: depositsError, - refetch: refetchDeposits, - } = useQuery(GET_DEPOSITS_BY_TERM_IDS, { - variables: { termIds }, - skip: termIds.length === 0, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy: depositsFetchPolicy, - nextFetchPolicy: 'cache-first', - }); - - // Calculate Total Market Cap = Σ(FOR + AGAINST) on filtered OFC triples - let totalMarketCap = 0n; - for (const triple of filteredTriples) { - // FOR votes (triple_vault) - if (triple.triple_vault?.total_assets) { - totalMarketCap += BigInt(triple.triple_vault.total_assets); - } - // AGAINST votes (counter_term) - if (triple.counter_term?.total_assets) { - totalMarketCap += BigInt(triple.counter_term.total_assets); - } - } - - // Calculate Total Holders = count distinct sender_id - const uniqueHolders = new Set(); - if (depositsData?.deposits) { - for (const deposit of depositsData.deposits) { - uniqueHolders.add(deposit.sender_id.toLowerCase()); } - } - - // Claims = number of distinct OFC triples - const claims = filteredTriples.length; - - const stats: FounderPanelStats = { - totalMarketCap, - formattedMarketCap: formatMarketCap(totalMarketCap), - totalHolders: uniqueHolders.size, - claims, - }; - // Combined loading state (includes category loading for OFC filtering) - const loading = triplesLoading || categoryLoading || (termIds.length > 0 && depositsLoading); - - // Combined error (return first error encountered) - const error = triplesError || depositsError; + // Total Holders = count distinct sender_id from deposits + const uniqueHolders = new Set(); + termIds.forEach((id) => { + const deposits = depositsByTermId.get(id); + if (deposits) { + deposits.forEach((d) => uniqueHolders.add(d.sender_id.toLowerCase())); + } + }); - // Combined refetch function - // Memoize refetch to prevent unnecessary re-renders - const refetch = useCallback(() => { - refetchTriples(); - if (termIds.length > 0) { - refetchDeposits(); - } - }, [refetchTriples, refetchDeposits, termIds.length]); + return { + totalMarketCap, + formattedMarketCap: formatMarketCap(totalMarketCap), + totalHolders: uniqueHolders.size, + claims: ofcTriples.length, + } as FounderPanelStats; + }, [proposalsByFounder, depositsByTermId, founderName, categoryMap]); - // Memoize return value to prevent unnecessary re-renders return useMemo(() => ({ stats, - loading, + loading: loading || categoryLoading, error: error as Error | undefined, refetch, - }), [stats, loading, error, refetch]); + }), [stats, loading, categoryLoading, error, refetch]); } diff --git a/apps/web/src/hooks/data/useFounderProposals.ts b/apps/web/src/hooks/data/useFounderProposals.ts index fc2f82d..c2a1a69 100644 --- a/apps/web/src/hooks/data/useFounderProposals.ts +++ b/apps/web/src/hooks/data/useFounderProposals.ts @@ -1,19 +1,15 @@ -import { useMemo, useCallback } from 'react'; +import { useMemo } from 'react'; import { useQuery } from '@apollo/client'; import { formatEther } from 'viem'; import { - GET_FOUNDER_PROPOSALS, - // GET_USER_PROPOSALS, // COMMENTED - useUserProposals disabled COUNT_USER_PROPOSALS_FOR_FOUNDER, } from '../../lib/graphql/queries'; import type { - GetFounderProposalsResult, - // GetUserProposalsResult, // COMMENTED - useUserProposals disabled CountUserProposalsForFounderResult, ProposalWithVotes, } from '../../lib/graphql/types'; import { enrichTripleWithVotes } from '../../utils/voteCalculations'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { useFoundersData } from '../../contexts/FoundersDataContext'; /** * Hook to fetch all proposals for a specific founder @@ -27,43 +23,20 @@ import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; * ``` */ export function useFounderProposals(founderName: string) { - // Use TTL-based cache policy: only fetch if data is older than 5 minutes - const fetchPolicy = getCacheFetchPolicy( - 'GetFounderProposals', - { founderName }, - FIVE_MINUTES - ); + const { proposalsByFounder, loading, error, refetch } = useFoundersData(); - const { data, loading, error, refetch } = useQuery( - GET_FOUNDER_PROPOSALS, - { - variables: { founderName }, - skip: !founderName, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy, - nextFetchPolicy: 'cache-first', - // Ensure refetch always goes to network - notifyOnNetworkStatusChange: true, - } - ); + const proposals: ProposalWithVotes[] = useMemo(() => { + const rawTriples = proposalsByFounder.get(founderName); + if (!rawTriples) return []; + return rawTriples.map(enrichTripleWithVotes); + }, [proposalsByFounder, founderName]); - const proposals: ProposalWithVotes[] = useMemo( - () => data?.triples.map(enrichTripleWithVotes) || [], - [data] - ); - - // Wrapped refetch that forces network-only to bypass cache after mutations - const forceRefetch = useCallback(() => { - return refetch({ fetchPolicy: 'network-only' } as Parameters[0]); - }, [refetch]); - - // Memoize return value to prevent unnecessary re-renders return useMemo(() => ({ proposals, loading, error, - refetch: forceRefetch, - }), [proposals, loading, error, forceRefetch]); + refetch, + }), [proposals, loading, error, refetch]); } /** diff --git a/apps/web/src/hooks/data/useFoundersForHomePage.ts b/apps/web/src/hooks/data/useFoundersForHomePage.ts index b93b6b0..13db913 100644 --- a/apps/web/src/hooks/data/useFoundersForHomePage.ts +++ b/apps/web/src/hooks/data/useFoundersForHomePage.ts @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef } from 'react'; +import { useMemo, useEffect, useRef, useCallback } from 'react'; import { useQuery } from '@apollo/client'; import { formatEther } from 'viem'; import foundersData from '../../../../../packages/shared/src/data/founders.json'; @@ -8,7 +8,7 @@ import type { Triple, GetDepositsByTermIdsResult } from '../../lib/graphql/types import { aggregateTriplesByObject } from '../../utils/aggregateVotes'; import type { TopTotem } from '../data/useTopTotems'; import { useAllOFCTotems } from './useAllOFCTotems'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { getCacheFetchPolicy, TWO_MINUTES } from '../../lib/queryCacheTTL'; // Re-export for backward compatibility export type { TrendDirection, WinningTotem, FounderForHomePage, CurveWinnerInfo }; @@ -83,7 +83,7 @@ export function useFoundersForHomePage() { const proposalsFetchPolicy = getCacheFetchPolicy( 'GetAllProposals', {}, // No variables - FIVE_MINUTES + TWO_MINUTES ); // Query 2: Get all proposals (triples) @@ -91,6 +91,7 @@ export function useFoundersForHomePage() { data: proposalsData, loading: proposalsLoading, error: proposalsError, + refetch: refetchProposals, } = useQuery(GET_ALL_PROPOSALS, { // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale fetchPolicy: proposalsFetchPolicy, @@ -141,7 +142,7 @@ export function useFoundersForHomePage() { const depositsFetchPolicy = getCacheFetchPolicy( 'GetDepositsByTermIds_AllFounders', {}, // No variables needed - batched for all founders - FIVE_MINUTES + TWO_MINUTES ); // Query 3: BATCHED deposits query - ONE query for ALL triples (term_id + counter_term_id) @@ -149,6 +150,7 @@ export function useFoundersForHomePage() { const { data: depositsData, loading: depositsLoading, + refetch: refetchDeposits, } = useQuery(GET_DEPOSITS_BY_TERM_IDS, { variables: { termIds: allTermIds }, skip: allTermIds.length === 0, @@ -491,6 +493,39 @@ export function useFoundersForHomePage() { } }, [loading, atomsLoading, proposalsLoading, categoryLoading, depositsLoading]); + // Build proposalsByFounder Map for Context sharing (Phase 2) + // Uses ALL triples (not OFC-filtered) so each hook can apply its own filters + const proposalsByFounder = useMemo(() => { + const map = new Map(); + if (!proposalsData?.triples) return map; + proposalsData.triples.forEach((t) => { + const name = t.subject?.label; + if (!name) return; + if (!map.has(name)) map.set(name, []); + map.get(name)!.push(t); + }); + return map; + }, [proposalsData?.triples]); + + // Build depositsByTermId Map for Context sharing (Phase 2) + const depositsByTermId = useMemo(() => { + const map = new Map(); + if (!depositsData?.deposits) return map; + depositsData.deposits.forEach((d) => { + if (!map.has(d.term_id)) map.set(d.term_id, []); + map.get(d.term_id)!.push(d); + }); + return map; + }, [depositsData?.deposits]); + + // Combined refetch for Context invalidation (after vote) + const refetch = useCallback(async () => { + await Promise.all([ + refetchProposals({ fetchPolicy: 'network-only' }), + refetchDeposits({ fetchPolicy: 'network-only' }), + ]); + }, [refetchProposals, refetchDeposits]); + // Memoize return value to prevent unnecessary re-renders return useMemo(() => ({ founders: result.founders, @@ -499,5 +534,9 @@ export function useFoundersForHomePage() { stats: result.stats, // NEW: Batched top totems map - avoids 42 individual queries topTotemsMap: result.topTotemsMap, - }), [result.founders, loading, error, result.stats, result.topTotemsMap]); + // Context data sharing (Phase 2) + proposalsByFounder, + depositsByTermId, + refetch, + }), [result.founders, loading, error, result.stats, result.topTotemsMap, proposalsByFounder, depositsByTermId, refetch]); } diff --git a/apps/web/src/hooks/data/useTopTotemsByCurve.ts b/apps/web/src/hooks/data/useTopTotemsByCurve.ts index bcef6d7..3820860 100644 --- a/apps/web/src/hooks/data/useTopTotemsByCurve.ts +++ b/apps/web/src/hooks/data/useTopTotemsByCurve.ts @@ -14,13 +14,11 @@ */ import { useMemo } from 'react'; -import { useQuery } from '@apollo/client'; import { formatEther } from 'viem'; import { useFounderProposals } from './useFounderProposals'; import { useAllOFCTotems } from './useAllOFCTotems'; import { filterValidTriples, type RawTriple } from '../../utils/tripleGuards'; -import { GET_DEPOSITS_FOR_TERMS_BY_CURVE } from '../../lib/graphql/queries'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { useFoundersData } from '../../contexts/FoundersDataContext'; /** Stats for a single curve */ export interface CurveStats { @@ -91,10 +89,6 @@ interface DepositNode { vault_type: string; } -interface GetDepositsForTermsByCurveResult { - deposits: DepositNode[]; -} - /** * Hook to get top totems with Linear/Progressive breakdown * @@ -112,49 +106,33 @@ interface GetDepositsForTermsByCurveResult { */ export function useTopTotemsByCurve(founderName: string): UseTopTotemsByCurveReturn { const { proposals, loading: proposalsLoading, error: proposalsError } = useFounderProposals(founderName); + const { depositsByTermId, loading: contextLoading } = useFoundersData(); const { categoryMap, loading: categoryLoading } = useAllOFCTotems(); // Filter valid proposals AND only keep totems with OFC category const validProposals = useMemo(() => { if (!proposals || proposals.length === 0) return []; const filtered = filterValidTriples(proposals as RawTriple[], 'useTopTotemsByCurve'); - // Only keep proposals where the totem (object) has an OFC category return filtered.filter((p) => categoryMap.has(p.object.term_id)); }, [proposals, categoryMap]); - // Collect all term_ids and counter_term_ids for deposits query - const termIds = useMemo(() => { - const ids: string[] = []; + // Collect deposits from Context instead of a separate query + const deposits = useMemo(() => { + const allDeposits: DepositNode[] = []; validProposals.forEach((p) => { - ids.push(p.term_id); + const forDeposits = depositsByTermId.get(p.term_id); + if (forDeposits) allDeposits.push(...forDeposits); if (p.counter_term?.id) { - ids.push(p.counter_term.id); + const againstDeposits = depositsByTermId.get(p.counter_term.id); + if (againstDeposits) allDeposits.push(...againstDeposits); } }); - return ids; - }, [validProposals]); - - // TTL-based cache policy: only fetch if data is older than 5 minutes - // Use founderName as key since termIds is derived from founder's proposals - const depositsFetchPolicy = getCacheFetchPolicy( - 'GetDepositsForTermsByCurve', - { founderName }, - FIVE_MINUTES - ); - - // Query deposits with curve breakdown - const { data: depositsData, loading: depositsLoading, error: depositsError } = - useQuery(GET_DEPOSITS_FOR_TERMS_BY_CURVE, { - variables: { termIds }, - skip: termIds.length === 0, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy: depositsFetchPolicy, - nextFetchPolicy: 'cache-first', - }); + return allDeposits; + }, [validProposals, depositsByTermId]); // Process deposits into totem stats const result = useMemo((): Omit => { - if (validProposals.length === 0 || !depositsData) { + if (validProposals.length === 0 || deposits.length === 0) { return { totems: [], linearWinner: null, @@ -208,7 +186,7 @@ export function useTopTotemsByCurve(founderName: string): UseTopTotemsByCurveRet let totalLinear = 0; let totalProgressive = 0; - depositsData.deposits.forEach((deposit) => { + deposits.forEach((deposit) => { const termInfo = termToTotemMap.get(deposit.term_id); if (!termInfo) return; @@ -296,13 +274,13 @@ export function useTopTotemsByCurve(founderName: string): UseTopTotemsByCurveRet totalLinearTrust: totalLinear, totalProgressiveTrust: totalProgressive, }; - }, [validProposals, depositsData]); + }, [validProposals, deposits]); // Memoize loading and error - const loading = proposalsLoading || depositsLoading || categoryLoading; + const loading = proposalsLoading || contextLoading || categoryLoading; const errorObj = useMemo( - () => (proposalsError || depositsError) as Error | undefined, - [proposalsError, depositsError] + () => proposalsError as Error | undefined, + [proposalsError] ); // Memoize return value to prevent unnecessary re-renders diff --git a/apps/web/src/hooks/data/useUserVotesForFounder.ts b/apps/web/src/hooks/data/useUserVotesForFounder.ts index b9cbfe5..0283e7b 100644 --- a/apps/web/src/hooks/data/useUserVotesForFounder.ts +++ b/apps/web/src/hooks/data/useUserVotesForFounder.ts @@ -14,13 +14,11 @@ import { useMemo, useCallback } from 'react'; import { useQuery } from '@apollo/client'; import { formatEther } from 'viem'; -import { - GET_USER_POSITIONS_FOR_TERMS, - GET_FOUNDER_TRIPLES_WITH_DETAILS, -} from '../../lib/graphql/queries'; +import { GET_USER_POSITIONS_FOR_TERMS } from '../../lib/graphql/queries'; import { filterValidTriples, type RawTriple } from '../../utils/tripleGuards'; import { formatSignedAmount } from '../../utils/formatters'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { getCacheFetchPolicy, TWO_MINUTES } from '../../lib/queryCacheTTL'; +import { useFoundersData } from '../../contexts/FoundersDataContext'; /** Predicates used for founder-totem relationships */ const FOUNDER_PREDICATES = ['has totem', 'embodies']; @@ -127,11 +125,6 @@ interface GetUserPositionsResult { positions: UserPosition[]; } -/** Result type for GET_FOUNDER_TRIPLES_WITH_DETAILS */ -interface GetFounderTriplesResult { - triples: TripleInfo[]; -} - /** * Hook to fetch user's votes for a specific founder * @@ -149,35 +142,16 @@ export function useUserVotesForFounder( walletAddress: string | undefined, founderName: string ): UseUserVotesForFounderReturn { + const { proposalsByFounder, loading: contextLoading, refetch: refetchContext } = useFoundersData(); + // Normalize wallet address to lowercase const normalizedAddress = walletAddress?.toLowerCase(); - // TTL-based cache policy: only fetch if data is older than 5 minutes - const triplesFetchPolicy = getCacheFetchPolicy( - 'GetFounderTriplesWithDetails', - { founderName }, - FIVE_MINUTES - ); - - // Query 1: Get founder's triples with full details (first, to get term_ids) - const { - data: triplesData, - loading: triplesLoading, - error: triplesError, - refetch: refetchTriples, - } = useQuery(GET_FOUNDER_TRIPLES_WITH_DETAILS, { - variables: { founderName }, - skip: !founderName, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy: triplesFetchPolicy, - nextFetchPolicy: 'cache-first', - }); - - // Filter valid triples first (removes those with null object/subject/predicate) + // Get triples from Context instead of query const validTriples = useMemo(() => { - if (!triplesData?.triples) return []; - return filterValidTriples(triplesData.triples as RawTriple[], 'useUserVotesForFounder'); - }, [triplesData?.triples]); + const proposals = proposalsByFounder.get(founderName) ?? []; + return filterValidTriples(proposals as RawTriple[], 'useUserVotesForFounder'); + }, [proposalsByFounder, founderName]); // Extract all term_ids (triple term_ids + counter_term_ids) for positions query // Also create map for later lookup @@ -204,7 +178,7 @@ export function useUserVotesForFounder( const positionsFetchPolicy = getCacheFetchPolicy( 'GetUserPositionsForTerms', { walletAddress: normalizedAddress, founderName }, - FIVE_MINUTES + TWO_MINUTES ); // Query 2: Get user's positions for founder's triples only (efficient) @@ -339,17 +313,16 @@ export function useUserVotesForFounder( ); // Combined loading state - const loading = positionsLoading || triplesLoading; + const loading = positionsLoading || contextLoading; // Combined error - const error = positionsError || triplesError; + const error = positionsError; // Combined refetch - forces network-only to bypass cache after mutations const refetch = useCallback(() => { - // Force network request, bypassing any cache refetchPositions({ fetchPolicy: 'network-only' }); - refetchTriples({ fetchPolicy: 'network-only' }); - }, [refetchPositions, refetchTriples]); + refetchContext(); + }, [refetchPositions, refetchContext]); // Memoize return value to prevent unnecessary re-renders return useMemo(() => ({ diff --git a/apps/web/src/hooks/data/useVoteMarketStats.ts b/apps/web/src/hooks/data/useVoteMarketStats.ts index 773da67..7a56992 100644 --- a/apps/web/src/hooks/data/useVoteMarketStats.ts +++ b/apps/web/src/hooks/data/useVoteMarketStats.ts @@ -15,7 +15,7 @@ import { useMemo } from 'react'; import { useQuery, gql } from '@apollo/client'; import { formatEther } from 'viem'; import { truncateAmount } from '../../utils/formatters'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { getCacheFetchPolicy, TWO_MINUTES } from '../../lib/queryCacheTTL'; /** * GraphQL query for founder vote market stats @@ -184,7 +184,7 @@ export function useVoteMarketStats( const fetchPolicy = getCacheFetchPolicy( 'GetFounderVoteMarket', { founderName }, - FIVE_MINUTES + TWO_MINUTES ); const { data, loading, error, refetch } = useQuery<{ diff --git a/apps/web/src/hooks/data/useVotesTimeline.ts b/apps/web/src/hooks/data/useVotesTimeline.ts index 34c1b7a..1fc5dec 100644 --- a/apps/web/src/hooks/data/useVotesTimeline.ts +++ b/apps/web/src/hooks/data/useVotesTimeline.ts @@ -13,16 +13,11 @@ */ import { useMemo } from 'react'; -import { useQuery } from '@apollo/client'; import { formatEther } from 'viem'; -import { - GET_FOUNDER_TRIPLES_WITH_DETAILS, - GET_DEPOSITS_FOR_TIMELINE, -} from '../../lib/graphql/queries'; import type { Timeframe, VoteDataPoint } from '../../components/graph/TradingChart'; import { filterValidTriples, type RawTriple } from '../../utils/tripleGuards'; import { truncateAmount } from '../../utils/formatters'; -import { getCacheFetchPolicy, FIVE_MINUTES } from '../../lib/queryCacheTTL'; +import { useFoundersData } from '../../contexts/FoundersDataContext'; /** * Triple info from query (may have null fields due to data integrity issues) @@ -180,65 +175,24 @@ export function useVotesTimeline( selectedTotemId?: string, curveFilter: CurveFilter = 'progressive' ): UseVotesTimelineResult { + const { proposalsByFounder, depositsByTermId, loading: contextLoading, error: contextError, refetch } = useFoundersData(); + // Fetch more data for All timeframe const limit = timeframe === 'All' ? 500 : 100; - // TTL-based cache policy: only fetch if data is older than 5 minutes - const triplesFetchPolicy = getCacheFetchPolicy( - 'GetFounderTriplesWithDetails_Timeline', - { founderName }, - FIVE_MINUTES - ); - - // Query 1: Get founder's triples to get term_ids - const { - data: triplesData, - loading: triplesLoading, - error: triplesError, - } = useQuery<{ triples: TripleInfo[] }>(GET_FOUNDER_TRIPLES_WITH_DETAILS, { - variables: { founderName }, - skip: !founderName, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy: triplesFetchPolicy, - nextFetchPolicy: 'cache-first', - }); - - // Filter valid triples first (removes those with null object/subject/predicate) + // Get triples from Context instead of query const validTriples = useMemo(() => { - if (!triplesData?.triples) return []; - return filterValidTriples(triplesData.triples as RawTriple[], 'useVotesTimeline'); - }, [triplesData?.triples]); - - // Extract term_ids AND counter_term_ids from valid triples - // We need BOTH to properly track FOR (term_id) and AGAINST (counter_term_id) votes - const termIds = useMemo(() => { - const ids: string[] = []; - validTriples.forEach((t) => { - ids.push(t.term_id); - // Use counter_term_id (direct field from GraphQL) or counter_term?.id (nested object) - const counterTermId = (t as TripleInfo).counter_term_id || t.counter_term?.id; - if (counterTermId) { - ids.push(counterTermId); - } - }); - return ids; - }, [validTriples]); + const proposals = proposalsByFounder.get(founderName) ?? []; + return filterValidTriples(proposals as RawTriple[], 'useVotesTimeline'); + }, [proposalsByFounder, founderName]); // Create a map: termId/counterTermId -> { objectId, isFor, totemLabel } - // This allows: - // 1. Filtering deposits by selected totem (using objectId) - // 2. Determining if a deposit is FOR or AGAINST (using isFor) - // 3. Getting totem label for activity feed (using totemLabel) const termToInfoMap = useMemo(() => { const map = new Map(); for (const triple of validTriples) { - // triple.object is guaranteed non-null by filterValidTriples const objectId = triple.object.term_id; const totemLabel = triple.object.label; - // Deposit on term_id = FOR vote map.set(triple.term_id, { objectId, isFor: true, totemLabel }); - // Deposit on counter_term_id = AGAINST vote - // Use counter_term_id (direct field from GraphQL) or counter_term?.id (nested object) const counterTermId = (triple as TripleInfo).counter_term_id || triple.counter_term?.id; if (counterTermId) { map.set(counterTermId, { objectId, isFor: false, totemLabel }); @@ -247,31 +201,34 @@ export function useVotesTimeline( return map; }, [validTriples]); - // TTL-based cache policy for deposits (per founder) - const depositsFetchPolicy = getCacheFetchPolicy( - 'GetDepositsForTimeline', - { founderName, limit }, - FIVE_MINUTES - ); - - // Query 2: Get deposits for those term_ids - const { - data: depositsData, - loading: depositsLoading, - error: depositsError, - refetch: refetchDeposits, - } = useQuery<{ deposits: RawDeposit[] }>(GET_DEPOSITS_FOR_TIMELINE, { - variables: { termIds, limit }, - skip: termIds.length === 0, - // TTL-based: 'cache-first' if fresh, 'cache-and-network' if stale - fetchPolicy: depositsFetchPolicy, - nextFetchPolicy: 'cache-first', - }); + // Get deposits from Context instead of query, apply sort + limit client-side + const allDeposits = useMemo((): RawDeposit[] => { + const termIds = [...termToInfoMap.keys()]; + const rawDeposits: RawDeposit[] = termIds.flatMap((id) => { + const deps = depositsByTermId.get(id); + if (!deps) return []; + return deps.map((d) => ({ + id: d.id, + sender_id: d.sender_id, + term_id: d.term_id, + vault_type: d.vault_type, + shares: d.shares, + assets_after_fees: d.assets_after_fees, + created_at: d.created_at, + transaction_hash: d.transaction_hash, + curve_id: d.curve_id, + })); + }); + // Sort by created_at DESC + limit (matches original query behavior) + return rawDeposits + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, limit); + }, [termToInfoMap, depositsByTermId, limit]); // Helper function to check if deposit matches curve filter const matchesCurveFilter = (deposit: RawDeposit): boolean => { if (curveFilter === 'all') return true; - const depositCurveId = deposit.curve_id || '1'; // Default to Linear if not specified + const depositCurveId = deposit.curve_id || '1'; if (curveFilter === 'progressive') return depositCurveId === '2'; if (curveFilter === 'linear') return depositCurveId === '1'; return true; @@ -279,21 +236,16 @@ export function useVotesTimeline( // Get deposits filtered only by totem and curve (not by timeframe) to check data availability const totemDeposits = useMemo(() => { - if (!depositsData?.deposits) return []; - - return depositsData.deposits.filter((d) => { - // Filter by curve + return allDeposits.filter((d) => { if (!matchesCurveFilter(d)) return false; - // Get term info (objectId and isFor) const termInfo = termToInfoMap.get(d.term_id); - if (!termInfo) return false; // Unknown deposit - // Filter by totem if specified + if (!termInfo) return false; if (selectedTotemId) { if (termInfo.objectId !== selectedTotemId) return false; } return true; }); - }, [depositsData?.deposits, selectedTotemId, termToInfoMap, curveFilter]); + }, [allDeposits, selectedTotemId, termToInfoMap, curveFilter]); // Check if data exists at all for this totem const hasAnyData = totemDeposits.length > 0; @@ -308,28 +260,24 @@ export function useVotesTimeline( ); const dataAge = now - oldestDepositTime; - // Check which timeframes would show data const timeframes: Timeframe[] = ['12H', '24H', '7D', 'All']; for (const tf of timeframes) { const duration = getTimeframeDuration(tf); - // If data age is within this timeframe, it would show data if (dataAge <= duration) { - // Only suggest if it's different from current timeframe if (tf !== timeframe) { return tf; } - return null; // Current timeframe is good + return null; } } - // Data is older than 7D, suggest "All" return timeframe !== 'All' ? 'All' : null; }, [hasAnyData, totemDeposits, timeframe]); // Process and aggregate data const { chartData, stats } = useMemo(() => { - if (!depositsData?.deposits || depositsData.deposits.length === 0) { + if (allDeposits.length === 0) { return { chartData: [], stats: { @@ -350,7 +298,7 @@ export function useVotesTimeline( // Note: We use termToInfoMap to: // 1. Match deposit.term_id to object.term_id (totem) // 2. Determine if deposit is FOR (term_id) or AGAINST (counter_term_id) - let filteredDeposits = depositsData.deposits.filter((d) => { + let filteredDeposits = allDeposits.filter((d: RawDeposit) => { const timestamp = new Date(d.created_at).getTime(); if (timeframe !== 'All' && timestamp < cutoff) return false; // Filter by curve type @@ -471,15 +419,13 @@ export function useVotesTimeline( voteCount: filteredDeposits.length, }, }; - }, [depositsData, timeframe, selectedTotemId, termToInfoMap, curveFilter]); + }, [allDeposits, timeframe, selectedTotemId, termToInfoMap, curveFilter]); // Recent votes for activity feed (5 most recent, all curves, no timeframe filter) const recentVotes = useMemo((): RecentVote[] => { - if (!depositsData?.deposits) return []; - - // Get deposits sorted by date desc, enrich with isFor and totemLabel - return depositsData.deposits - .map((deposit) => { + // allDeposits is already sorted by created_at DESC + return allDeposits + .map((deposit: RawDeposit) => { const termInfo = termToInfoMap.get(deposit.term_id); if (!termInfo) return null; return { @@ -493,24 +439,20 @@ export function useVotesTimeline( }) .filter((v): v is RecentVote => v !== null) .slice(0, 5); - }, [depositsData?.deposits, termToInfoMap]); - - // Combined loading and error states - const loading = triplesLoading || depositsLoading; - const error = triplesError || depositsError; + }, [allDeposits, termToInfoMap]); // Memoize error object to prevent unnecessary re-renders - const errorObj = useMemo(() => error ? new Error(error.message) : null, [error]); + const errorObj = useMemo(() => contextError ? new Error(contextError.message) : null, [contextError]); // Memoize return value to prevent unnecessary re-renders return useMemo(() => ({ data: chartData, - loading, + loading: contextLoading, error: errorObj, - refetch: refetchDeposits, + refetch, stats, suggestedTimeframe, hasAnyData, recentVotes, - }), [chartData, loading, errorObj, refetchDeposits, stats, suggestedTimeframe, hasAnyData, recentVotes]); + }), [chartData, contextLoading, errorObj, refetch, stats, suggestedTimeframe, hasAnyData, recentVotes]); } diff --git a/apps/web/src/lib/graphql/queries.ts b/apps/web/src/lib/graphql/queries.ts index 11c3d4a..54cea95 100644 --- a/apps/web/src/lib/graphql/queries.ts +++ b/apps/web/src/lib/graphql/queries.ts @@ -277,17 +277,27 @@ export const GET_ALL_PROPOSALS = gql` term_id label image + emoji + type } predicate { term_id label + image + emoji } object { term_id label image emoji + type + } + creator { + id } + creator_id + counter_term_id triple_vault { total_shares total_assets @@ -296,6 +306,8 @@ export const GET_ALL_PROPOSALS = gql` id total_assets } + block_number + transaction_hash created_at } } @@ -906,11 +918,15 @@ export const GET_DEPOSITS_BY_TERM_IDS = gql` vault_type: { _in: ["Triple", "CounterTriple"] } } ) { + id term_id sender_id vault_type curve_id + shares assets_after_fees + created_at + transaction_hash } } `; diff --git a/apps/web/src/lib/graphql/types.ts b/apps/web/src/lib/graphql/types.ts index d1624a2..c7e7215 100644 --- a/apps/web/src/lib/graphql/types.ts +++ b/apps/web/src/lib/graphql/types.ts @@ -80,6 +80,7 @@ export interface Triple { total_shares: string; total_assets: string; }; + counter_term_id?: string; counter_term?: { id: string; total_assets: string; @@ -430,10 +431,14 @@ export interface GetFounderPanelStatsResult { */ export interface GetDepositsByTermIdsResult { deposits: Array<{ + id: string; term_id: string; sender_id: string; vault_type: string; curve_id: string; + shares: string; assets_after_fees: string; + created_at: string; + transaction_hash: string; }>; } diff --git a/apps/web/src/lib/queryCacheTTL.ts b/apps/web/src/lib/queryCacheTTL.ts index 069aca6..24b3939 100644 --- a/apps/web/src/lib/queryCacheTTL.ts +++ b/apps/web/src/lib/queryCacheTTL.ts @@ -14,11 +14,12 @@ import type { WatchQueryFetchPolicy } from '@apollo/client'; /** Time constants */ export const ONE_SECOND = 1000; export const ONE_MINUTE = ONE_SECOND * 60; +export const TWO_MINUTES = ONE_MINUTE * 2; export const FIVE_MINUTES = ONE_MINUTE * 5; export const TEN_MINUTES = ONE_MINUTE * 10; -/** Default TTL for queries (5 minutes) */ -export const DEFAULT_TTL = FIVE_MINUTES; +/** Default TTL for queries (2 minutes) */ +export const DEFAULT_TTL = TWO_MINUTES; /** Map storing last fetch timestamps by query key */ const queryTimestamps = new Map(); diff --git a/apps/web/src/pages/HomePage3DCarousel.tsx b/apps/web/src/pages/HomePage3DCarousel.tsx index a1341d2..43bc093 100644 --- a/apps/web/src/pages/HomePage3DCarousel.tsx +++ b/apps/web/src/pages/HomePage3DCarousel.tsx @@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next'; import { FounderHomeCard } from '../components/founder'; import { FounderExpandedView } from '../components/founder'; import { AlphabetIndex } from '../components/founder/AlphabetIndex/AlphabetIndex'; -import { useFoundersForHomePage, type FounderForHomePage, type TopTotem } from '../hooks'; +import { useFoundersData } from '../contexts/FoundersDataContext'; +import type { FounderForHomePage } from '../types/founder'; +import type { TopTotem } from '../hooks/data/useTopTotems'; import { useTextScramble } from '../hooks/ui/useTextScramble'; import '../carousel-3d.css'; @@ -253,7 +255,7 @@ function HeroSection({ stats, loading }: { stats: { totalTrustVoted: number; uni */ export function HomePage3DCarousel() { const { t } = useTranslation(); - const { founders, loading, error, stats, topTotemsMap } = useFoundersForHomePage(); + const { founders, loading, error, stats, topTotemsMap } = useFoundersData(); const [searchParams, setSearchParams] = useSearchParams(); // État de rotation du carrousel unique (en degrés) diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 438b5e7..3f541b6 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -3,6 +3,7 @@ import { lazy, Suspense } from 'react'; import { Layout } from './components/layout/Layout'; import { NetworkGuard } from './components/layout/NetworkGuard'; import { HomePage3DCarousel } from './pages/HomePage3DCarousel'; +import { FoundersDataProvider } from './contexts/FoundersDataContext'; // Lazy loaded pages (not needed on initial load) const AdminAuditPage = lazy(() => import('./pages/AdminAuditPage').then(m => ({ default: m.AdminAuditPage }))); @@ -35,7 +36,7 @@ export const router = createBrowserRouter([ children: [ { index: true, - element: , + element: , }, { path: 'admin/audit', From c9bcdc3aec895159ec47745b1c4506ef3dc83c97 Mon Sep 17 00:00:00 2001 From: Dev-Moulin Date: Mon, 16 Feb 2026 12:09:22 +0100 Subject: [PATCH 2/2] fix: update useFounderProposals tests to mock Context instead of useQuery --- .../hooks/data/useFounderProposals.test.ts | 167 ++++++------------ 1 file changed, 56 insertions(+), 111 deletions(-) diff --git a/apps/web/src/hooks/data/useFounderProposals.test.ts b/apps/web/src/hooks/data/useFounderProposals.test.ts index a982672..8b7bf8d 100644 --- a/apps/web/src/hooks/data/useFounderProposals.test.ts +++ b/apps/web/src/hooks/data/useFounderProposals.test.ts @@ -1,16 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -// Mock Apollo client before importing +// Mock Apollo client (still needed by useProposalLimit) vi.mock('@apollo/client', () => ({ useQuery: vi.fn(), gql: vi.fn((strings: TemplateStringsArray) => strings.join('')), })); +// Mock FoundersDataContext (used by useFounderProposals) +const mockRefetch = vi.fn().mockResolvedValue(undefined); +vi.mock('../../contexts/FoundersDataContext', () => ({ + useFoundersData: vi.fn(), +})); + import { useQuery } from '@apollo/client'; +import { useFoundersData } from '../../contexts/FoundersDataContext'; import { useFounderProposals, - // useUserProposals, // COMMENTED - not exported from hook useProposalLimit, sortProposalsByVotes, getWinningProposal, @@ -50,21 +56,32 @@ const mockTriplesWithoutVaults = [ }, ]; -describe('useFounderProposals', () => { - const mockRefetch = vi.fn(); +/** Helper to mock useFoundersData with a proposalsByFounder Map */ +function mockContextWith(triples: any[], founderName: string, overrides: Partial<{ loading: boolean; error: any }> = {}) { + const map = new Map(); + if (triples.length > 0) { + map.set(founderName, triples); + } + vi.mocked(useFoundersData).mockReturnValue({ + founders: [], + stats: { totalTrustVoted: 0, uniqueVoters: 0, foundersWithTotems: 0, totalProposals: 0 }, + topTotemsMap: new Map(), + proposalsByFounder: map, + depositsByTermId: new Map(), + loading: overrides.loading ?? false, + error: overrides.error ?? null, + refetch: mockRefetch, + } as any); +} +describe('useFounderProposals', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('loading state', () => { - it('should return loading true when query is loading', () => { - vi.mocked(useQuery).mockReturnValue({ - data: undefined, - loading: true, - error: undefined, - refetch: mockRefetch, - } as any); + it('should return loading true when context is loading', () => { + mockContextWith([], 'Joseph Lubin', { loading: true }); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); @@ -75,27 +92,17 @@ describe('useFounderProposals', () => { describe('successful query', () => { it('should return proposals after loading', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith(mockTriples, 'Joseph Lubin'); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); expect(result.current.loading).toBe(false); expect(result.current.proposals.length).toBe(2); - expect(result.current.error).toBeUndefined(); + expect(result.current.error).toBeNull(); }); it('should include vote counts in proposals', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith(mockTriples, 'Joseph Lubin'); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); @@ -106,12 +113,7 @@ describe('useFounderProposals', () => { }); it('should calculate net votes', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith(mockTriples, 'Joseph Lubin'); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); @@ -121,12 +123,7 @@ describe('useFounderProposals', () => { }); it('should calculate percentage correctly', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith(mockTriples, 'Joseph Lubin'); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); @@ -140,12 +137,7 @@ describe('useFounderProposals', () => { }); it('should handle zero votes (percentage = 0)', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriplesWithoutVaults }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith(mockTriplesWithoutVaults, 'Test Founder'); const { result } = renderHook(() => useFounderProposals('Test Founder')); @@ -155,30 +147,18 @@ describe('useFounderProposals', () => { expect(proposal.votes.againstVotes).toBe('0'); }); - it('should provide refetch function that forces network-only', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + it('should provide refetch function', () => { + mockContextWith(mockTriples, 'Joseph Lubin'); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); expect(typeof result.current.refetch).toBe('function'); - result.current.refetch(); - expect(mockRefetch).toHaveBeenCalledWith({ fetchPolicy: 'network-only' }); }); }); describe('empty founder name', () => { - it('should skip query when founderName is empty', () => { - vi.mocked(useQuery).mockReturnValue({ - data: undefined, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + it('should return empty proposals when founderName is empty', () => { + mockContextWith([], ''); const { result } = renderHook(() => useFounderProposals('')); @@ -188,12 +168,7 @@ describe('useFounderProposals', () => { describe('empty results', () => { it('should return empty array when no proposals found', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: [] }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith([], 'Unknown Founder'); const { result } = renderHook(() => useFounderProposals('Unknown Founder')); @@ -202,14 +177,9 @@ describe('useFounderProposals', () => { }); describe('error handling', () => { - it('should return error when query fails', () => { + it('should return error when context has error', () => { const mockError = new Error('GraphQL error'); - vi.mocked(useQuery).mockReturnValue({ - data: undefined, - loading: false, - error: mockError, - refetch: mockRefetch, - } as any); + mockContextWith([], 'Error Founder', { error: mockError }); const { result } = renderHook(() => useFounderProposals('Error Founder')); @@ -220,12 +190,7 @@ describe('useFounderProposals', () => { describe('proposal structure', () => { it('should include triple data in proposal', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); + mockContextWith(mockTriples, 'Joseph Lubin'); const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); @@ -236,42 +201,22 @@ describe('useFounderProposals', () => { }); }); - describe('query configuration', () => { - it('should call useQuery with correct variables', () => { - vi.mocked(useQuery).mockReturnValue({ - data: { triples: mockTriples }, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); - - renderHook(() => useFounderProposals('Joseph Lubin')); - - expect(useQuery).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - variables: { founderName: 'Joseph Lubin' }, - skip: false, - }) - ); + describe('context integration', () => { + it('should read from proposalsByFounder Map', () => { + mockContextWith(mockTriples, 'Joseph Lubin'); + + const { result } = renderHook(() => useFounderProposals('Joseph Lubin')); + + expect(useFoundersData).toHaveBeenCalled(); + expect(result.current.proposals.length).toBe(2); }); - it('should skip query when founderName is empty', () => { - vi.mocked(useQuery).mockReturnValue({ - data: undefined, - loading: false, - error: undefined, - refetch: mockRefetch, - } as any); - - renderHook(() => useFounderProposals('')); - - expect(useQuery).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - skip: true, - }) - ); + it('should return empty when founder not in Map', () => { + mockContextWith(mockTriples, 'Joseph Lubin'); + + const { result } = renderHook(() => useFounderProposals('Unknown')); + + expect(result.current.proposals).toEqual([]); }); }); });