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([]);
});
});
});