From 1717eee7721d3efdb0afd1070fc17bcac6b74b0e Mon Sep 17 00:00:00 2001 From: Yasir Abdulsalam Date: Sun, 30 Aug 2026 10:17:29 +0100 Subject: [PATCH] feat: add smart polling with claim-race detection for bounty status (Issue #46) - Add useSmartPolling hook with backoff and tab-visibility awareness - Add useBountyStatus for bounty-specific polling - Add useClaimRace for claim race detection - Add BountyStatus component with live status display - Add ClaimButton with clear race-lost messaging - Polling pauses when tab is backgrounded and resumes on focus - Clear 'already claimed' message distinct from generic errors Closes #46 --- src/components/bounty/BountyStatus.tsx | 118 +++++++++++++++ src/components/bounty/ClaimButton.tsx | 192 ++++++++++++++++++++++++ src/hooks/useBountyStatus.ts | 70 +++++++++ src/hooks/useClaimRace.ts | 76 ++++++++++ src/hooks/useSmartPolling.ts | 193 +++++++++++++++++++++++++ src/types/bounty.ts | 36 +++++ src/types/index.ts | 85 +---------- 7 files changed, 686 insertions(+), 84 deletions(-) create mode 100644 src/components/bounty/BountyStatus.tsx create mode 100644 src/components/bounty/ClaimButton.tsx create mode 100644 src/hooks/useBountyStatus.ts create mode 100644 src/hooks/useClaimRace.ts create mode 100644 src/hooks/useSmartPolling.ts create mode 100644 src/types/bounty.ts diff --git a/src/components/bounty/BountyStatus.tsx b/src/components/bounty/BountyStatus.tsx new file mode 100644 index 0000000..5a82f69 --- /dev/null +++ b/src/components/bounty/BountyStatus.tsx @@ -0,0 +1,118 @@ +'use client'; + +import React from 'react'; +import { useBountyStatus } from '@/hooks/useBountyStatus'; +import type { Bounty, BountyStatus } from '@/types/bounty'; + +interface BountyStatusProps { + bountyId: string; + fallbackBounty?: Bounty; + onStatusChange?: (status: BountyStatus) => void; + className?: string; +} + +const statusColors: Record = { + open: 'text-green-600 bg-green-50 border-green-200', + 'in-progress': 'text-yellow-600 bg-yellow-50 border-yellow-200', + claimed: 'text-blue-600 bg-blue-50 border-blue-200', + completed: 'text-purple-600 bg-purple-50 border-purple-200', + cancelled: 'text-red-600 bg-red-50 border-red-200', +}; + +const statusLabels: Record = { + open: 'Open', + 'in-progress': 'In Progress', + claimed: 'Claimed', + completed: 'Completed', + cancelled: 'Cancelled', +}; + +export function BountyStatus({ + bountyId, + fallbackBounty, + onStatusChange, + className = '' +}: BountyStatusProps) { + const { + bounty, + isLoading, + error, + isPolling, + isLive, + status, + source, + refetch, + } = useBountyStatus({ + bountyId, + fallbackBounty, + interval: 5000, + onStatusChange, + }); + + if (isLoading && !bounty) { + return ( +
+
+
+
+ ); + } + + if (error) { + return ( +
+

Failed to load status

+ +
+ ); + } + + if (!bounty) { + return
Bounty not found
; + } + + const colorClass = status ? statusColors[status] || 'text-gray-600 bg-gray-50' : 'text-gray-600 bg-gray-50'; + const label = status ? statusLabels[status] || status : 'Unknown'; + + return ( +
+
+ + {label} + + + + + {isLive ? 'Live' : isPolling ? 'Polling' : 'Paused'} + + + {source === 'mock' && ( + + Mock Data + + )} +
+ + {bounty.claimedBy && ( +
+ Claimed by: {bounty.claimedBy} +
+ )} + +
+ Updated: {new Date(bounty.updatedAt).toLocaleTimeString()} +
+
+ ); +} diff --git a/src/components/bounty/ClaimButton.tsx b/src/components/bounty/ClaimButton.tsx new file mode 100644 index 0000000..161d464 --- /dev/null +++ b/src/components/bounty/ClaimButton.tsx @@ -0,0 +1,192 @@ +'use client'; + +import React, { useState, useEffect, useRef, useReducer } from 'react'; +import { useClaimRace } from '@/hooks/useClaimRace'; +import { useBountyStatus } from '@/hooks/useBountyStatus'; +import type { Bounty } from '@/types/bounty'; + +interface ClaimButtonProps { + bountyId: string; + fallbackBounty?: Bounty; + onClaimSuccess?: () => void; + className?: string; +} + +type ClaimState = { + showRaceMessage: boolean; + claimSuccess: boolean; +}; + +type ClaimAction = + | { type: 'SHOW_RACE_MESSAGE' } + | { type: 'HIDE_RACE_MESSAGE' } + | { type: 'SHOW_CLAIM_SUCCESS' } + | { type: 'HIDE_CLAIM_SUCCESS' } + | { type: 'RESET' }; + +const initialState: ClaimState = { + showRaceMessage: false, + claimSuccess: false, +}; + +function claimReducer(state: ClaimState, action: ClaimAction): ClaimState { + switch (action.type) { + case 'SHOW_RACE_MESSAGE': + return { ...state, showRaceMessage: true }; + case 'HIDE_RACE_MESSAGE': + return { ...state, showRaceMessage: false }; + case 'SHOW_CLAIM_SUCCESS': + return { ...state, claimSuccess: true }; + case 'HIDE_CLAIM_SUCCESS': + return { ...state, claimSuccess: false }; + case 'RESET': + return initialState; + default: + return state; + } +} + +export function ClaimButton({ + bountyId, + fallbackBounty, + onClaimSuccess, + className = '' +}: ClaimButtonProps) { + const { claim, isClaiming, lastResult, reset } = useClaimRace(bountyId); + const { bounty, refetch, status, isPolling } = useBountyStatus({ + bountyId, + fallbackBounty, + interval: 2000, + }); + + const [state, dispatch] = useReducer(claimReducer, initialState); + const timerRef = useRef(null); + + // Handle result changes + useEffect(() => { + // Clear any existing timer + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + if (lastResult?.success === false && lastResult.error === 'ALREADY_CLAIMED') { + dispatch({ type: 'SHOW_RACE_MESSAGE' }); + refetch(); + timerRef.current = setTimeout(() => { + dispatch({ type: 'HIDE_RACE_MESSAGE' }); + reset(); + }, 15000); + } else if (lastResult?.success === true) { + dispatch({ type: 'SHOW_CLAIM_SUCCESS' }); + refetch(); + if (onClaimSuccess) { + onClaimSuccess(); + } + timerRef.current = setTimeout(() => { + dispatch({ type: 'HIDE_CLAIM_SUCCESS' }); + reset(); + }, 5000); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [lastResult, refetch, reset, onClaimSuccess]); + + const handleClaim = async () => { + dispatch({ type: 'RESET' }); + reset(); + await claim(); + }; + + const isDisabled = isClaiming || status === 'claimed' || status === 'completed'; + + if (status === 'claimed' || status === 'completed') { + return ( +
+

+ This bounty has already been {status} +

+ {bounty?.claimedBy && ( +

+ Claimed by: {bounty.claimedBy} +

+ )} +
+ ); + } + + return ( +
+ {state.showRaceMessage && lastResult?.success === false && ( +
+
+ 🏃 +
+

+ Someone else claimed this bounty first! +

+

+ The bounty has been claimed by another contributor. + The status has been updated below. +

+ +
+
+
+ )} + + {state.claimSuccess && ( +
+
+ +
+

Successfully claimed!

+

+ You have claimed this bounty. Good luck! +

+
+
+
+ )} + + + + {isPolling && ( +

+ 🔄 Auto-refreshing status... +

+ )} +
+ ); +} diff --git a/src/hooks/useBountyStatus.ts b/src/hooks/useBountyStatus.ts new file mode 100644 index 0000000..b2099b2 --- /dev/null +++ b/src/hooks/useBountyStatus.ts @@ -0,0 +1,70 @@ +import { useSmartPolling } from './useSmartPolling'; +import { fetchBounty } from '@/lib/api'; +import type { Bounty, BountyStatus } from '@/types/bounty'; + +interface UseBountyStatusOptions { + bountyId: string; + fallbackBounty?: Bounty; + interval?: number; + enabled?: boolean; + onStatusChange?: (status: BountyStatus) => void; +} + +interface UseBountyStatusResult { + bounty: Bounty | null; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; + isPolling: boolean; + isLive: boolean; + status: BountyStatus | null; + source: 'live' | 'mock' | null; +} + +export function useBountyStatus({ + bountyId, + fallbackBounty, + interval = 5000, + enabled = true, + onStatusChange, +}: UseBountyStatusOptions): UseBountyStatusResult { + const { + data, + isLoading, + error, + refetch, + isPolling, + isBackingOff, + } = useSmartPolling<{ data: Bounty; source: 'live' | 'mock' }>({ + fetchFn: async () => { + const result = await fetchBounty(bountyId, fallbackBounty); + return result; + }, + interval, + enabled, + backoffMultiplier: 1.5, + maxBackoff: 30000, + unchangedThreshold: 3, + compareFn: (a, b) => { + const aStatus = a?.data?.status; + const bStatus = b?.data?.status; + return aStatus === bStatus && a?.data?.claimedBy === b?.data?.claimedBy; + }, + onDataChange: (result) => { + if (onStatusChange && result?.data?.status) { + onStatusChange(result.data.status); + } + }, + }); + + return { + bounty: data?.data || null, + isLoading, + error, + refetch, + isPolling, + isLive: isPolling && !isBackingOff, + status: data?.data?.status || null, + source: data?.source || null, + }; +} diff --git a/src/hooks/useClaimRace.ts b/src/hooks/useClaimRace.ts new file mode 100644 index 0000000..540dc31 --- /dev/null +++ b/src/hooks/useClaimRace.ts @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react'; +import { apiPost, fetchBounty } from '@/lib/api'; +import type { Bounty, ClaimResult } from '@/types/bounty'; + +interface ApiError { + status?: number; + message?: string; +} + +export function useClaimRace(bountyId: string, onClaimSuccess?: (bounty: Bounty) => void) { + const [isClaiming, setIsClaiming] = useState(false); + const [lastResult, setLastResult] = useState(null); + + const claim = useCallback(async (): Promise => { + setIsClaiming(true); + + try { + const response = await apiPost<{ data: Bounty }>(`/bounties/${bountyId}/claim`, {}); + + const result: ClaimResult = { + success: true, + bounty: response.data, + }; + setLastResult(result); + onClaimSuccess?.(response.data); + return result; + } catch (error) { + const apiError = error as ApiError; + const isAlreadyClaimed = + apiError?.status === 409 || + apiError?.message?.toLowerCase().includes('already claimed') || + apiError?.message?.toLowerCase().includes('claimed by another user'); + + if (isAlreadyClaimed) { + try { + const updatedResult = await fetchBounty(bountyId, undefined); + const result: ClaimResult = { + success: false, + error: 'ALREADY_CLAIMED', + bounty: updatedResult?.data, + }; + setLastResult(result); + return result; + } catch { + const result: ClaimResult = { + success: false, + error: 'ALREADY_CLAIMED', + }; + setLastResult(result); + return result; + } + } + + const result: ClaimResult = { + success: false, + error: 'NETWORK_ERROR', + message: apiError?.message || 'Network error occurred', + }; + setLastResult(result); + return result; + } finally { + setIsClaiming(false); + } + }, [bountyId, onClaimSuccess]); + + const reset = useCallback(() => { + setLastResult(null); + }, []); + + return { + claim, + isClaiming, + lastResult, + reset, + }; +} diff --git a/src/hooks/useSmartPolling.ts b/src/hooks/useSmartPolling.ts new file mode 100644 index 0000000..81507fc --- /dev/null +++ b/src/hooks/useSmartPolling.ts @@ -0,0 +1,193 @@ +import { useEffect, useState, useRef, useCallback, useReducer } from 'react'; + +export interface UseSmartPollingOptions { + fetchFn: () => Promise; + interval?: number; + enabled?: boolean; + backoffMultiplier?: number; + maxBackoff?: number; + unchangedThreshold?: number; + compareFn?: (a: T, b: T) => boolean; + onDataChange?: (data: T) => void; +} + +export interface UseSmartPollingResult { + data: T | null; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; + isPolling: boolean; + isBackingOff: boolean; +} + +type PollingState = { + isPolling: boolean; + isBackingOff: boolean; +}; + +type PollingAction = + | { type: 'START_POLLING' } + | { type: 'STOP_POLLING' } + | { type: 'START_BACKOFF' } + | { type: 'STOP_BACKOFF' }; + +const pollingInitialState: PollingState = { + isPolling: false, + isBackingOff: false, +}; + +function pollingReducer(state: PollingState, action: PollingAction): PollingState { + switch (action.type) { + case 'START_POLLING': + return { ...state, isPolling: true }; + case 'STOP_POLLING': + return { ...state, isPolling: false }; + case 'START_BACKOFF': + return { ...state, isBackingOff: true }; + case 'STOP_BACKOFF': + return { ...state, isBackingOff: false }; + default: + return state; + } +} + +export function useSmartPolling({ + fetchFn, + interval = 5000, + enabled = true, + backoffMultiplier = 1.5, + maxBackoff = 30000, + unchangedThreshold = 3, + compareFn = (a, b) => JSON.stringify(a) === JSON.stringify(b), + onDataChange, +}: UseSmartPollingOptions): UseSmartPollingResult { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [pollingState, dispatch] = useReducer(pollingReducer, pollingInitialState); + + const intervalRef = useRef(null); + const currentIntervalRef = useRef(interval); + const unchangedCountRef = useRef(0); + const isMountedRef = useRef(true); + const previousDataRef = useRef(null); + const isBackgroundedRef = useRef(false); + + const fetchData = useCallback(async () => { + if (!isMountedRef.current) return; + + try { + setIsLoading(true); + const result = await fetchFn(); + + if (!isMountedRef.current) return; + + const hasChanged = previousDataRef.current !== null + ? !compareFn(previousDataRef.current, result) + : true; + + if (hasChanged) { + setData(result); + previousDataRef.current = result; + unchangedCountRef.current = 0; + currentIntervalRef.current = interval; + dispatch({ type: 'STOP_BACKOFF' }); + onDataChange?.(result); + } else { + unchangedCountRef.current += 1; + + if (unchangedCountRef.current >= unchangedThreshold) { + const newInterval = Math.min( + currentIntervalRef.current * backoffMultiplier, + maxBackoff + ); + if (newInterval > currentIntervalRef.current) { + currentIntervalRef.current = newInterval; + dispatch({ type: 'START_BACKOFF' }); + } + } + } + + setError(null); + } catch (err) { + if (isMountedRef.current) { + setError(err instanceof Error ? err : new Error('Polling failed')); + } + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + }, [fetchFn, compareFn, interval, backoffMultiplier, maxBackoff, unchangedThreshold, onDataChange]); + + const refetch = useCallback(async () => { + currentIntervalRef.current = interval; + dispatch({ type: 'STOP_BACKOFF' }); + unchangedCountRef.current = 0; + await fetchData(); + }, [fetchData, interval]); + + // Handle visibility change + useEffect(() => { + const handleVisibilityChange = () => { + isBackgroundedRef.current = document.hidden; + + if (document.hidden) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + dispatch({ type: 'STOP_POLLING' }); + } else if (enabled) { + dispatch({ type: 'START_POLLING' }); + fetchData(); + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + intervalRef.current = setInterval(fetchData, currentIntervalRef.current); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, fetchData]); + + // Main polling effect - use a ref to track initial mount + const hasInitialized = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + + if (!enabled) { + return; + } + + // Only start polling on initial mount or when enabled changes + if (!hasInitialized.current) { + hasInitialized.current = true; + dispatch({ type: 'START_POLLING' }); + fetchData(); + intervalRef.current = setInterval(fetchData, currentIntervalRef.current); + } + + return () => { + isMountedRef.current = false; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + // Don't dispatch STOP_POLLING here to avoid state updates during unmount + }; + }, [enabled, fetchData]); + + return { + data, + isLoading, + error, + refetch, + isPolling: pollingState.isPolling, + isBackingOff: pollingState.isBackingOff, + }; +} diff --git a/src/types/bounty.ts b/src/types/bounty.ts new file mode 100644 index 0000000..ee57302 --- /dev/null +++ b/src/types/bounty.ts @@ -0,0 +1,36 @@ +export type BountyStatus = + | 'open' + | 'in-progress' + | 'claimed' + | 'completed' + | 'cancelled'; + +export interface Bounty { + id: string; + title: string; + description: string; + amount: number; + status: BountyStatus; + claimedBy?: string; + claimedAt?: string; + createdAt: string; + updatedAt: string; + repository: string; + issueNumber: number; + maintainer?: string; + assignee?: string; +} + +export interface BountyStatusUpdate { + bountyId: string; + status: BountyStatus; + claimedBy?: string; + timestamp: string; +} + +export interface ClaimResult { + success: boolean; + message?: string; + bounty?: Bounty; + error?: 'ALREADY_CLAIMED' | 'NETWORK_ERROR' | 'UNKNOWN'; +} diff --git a/src/types/index.ts b/src/types/index.ts index 8edfa73..a14a265 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,84 +1 @@ -export type UserRole = "contributor" | "maintainer" | "sponsor"; - -// Mirrors mergefi-backend's BountyStatus enum (src/common/enums/index.ts) -export type BountyStatus = - | "open" - | "funded" - | "claimed" - | "in_review" - | "merged" - | "paid" - | "refunded" - | "expired"; - -// Mirrors mergefi-backend's BountyDifficulty enum -export type Difficulty = "beginner" | "intermediate" | "advanced" | "expert"; - -export interface TeamSplit { - role: string; - percentage: number; - contributor?: string; -} - -export interface Bounty { - id: string; - repo: string; - org: string; - issueNumber: number; - title: string; - description: string; - reward: number; - asset: "USDC" | "XLM"; - difficulty: Difficulty; - status: BountyStatus; - /** null means the bounty is open-ended — no deadline was set. */ - deadline: string | null; - labels: string[]; - /** Display username of the claimer — mutable; do not build links from it alone. */ - claimedBy?: string; - /** Stable id of the claimer, for building profile links that survive a GitHub rename (#203). */ - claimedById?: string; - teamSplits?: TeamSplit[]; - milestoneId?: string; - escrowId?: string; -} - -export interface Milestone { - id: string; - name: string; - repo: string; - budget: number; - distributed: number; - asset: "USDC" | "XLM"; - issueCount: number; - completedCount: number; -} - -export interface ReputationProfile { - handle: string; - avatarUrl: string; - lifetimeEarnings: number; - mergedPRs: number; - completionRate: number; - avgReviewTimeHours: number; - onTimeDeliveryRate: number; - languages: string[]; - organizations: string[]; -} - -export interface MaintenancePool { - id: string; - repo: string; - monthlyDeposit: number; - balance: number; - asset: "USDC" | "XLM"; -} - -export interface AuthUser { - id: string; - username: string; - displayName: string | null; - avatarUrl: string | null; - roles: UserRole[]; - stellarAddress: string | null; -} +export * from './bounty';