Custom React hook that manages optimistic up/down voting for roadmap cards, persisting vote state to `localStorage` with one vote per task per user and syncing changes to a configurable API endpoint. ## Key Components ### Types | Type | Description | |------|-------------| | `VoteType` | `'up' \| 'down' \| null` — current vote state for a task | | `VoteState` | Record mapping `taskId → VoteType` | | `UseRoadmapVotingOptions` | Hook configuration options | ### `UseRoadmapVotingOptions` | Option | Default | Description | |--------|---------|-------------| | `voteApiEndpoint` | `/api/roadmap/vote` | Vote POST endpoint; override for reverse-proxy embedders | | `storageKey` | `roadmap_votes_v1` | `localStorage` key; scope per surface to avoid cross-contamination | ### Hook Return Values | Value | Type | Description | |-------|------|-------------| | `votes` | `VoteState` | All current votes keyed by `taskId` | | `isLoading` | `boolean` | `true` while hydrating from `localStorage` | | `getVote` | `(taskId: string) => VoteType` | Returns current vote for a task | | `toggleVote` | `(taskId, voteType) => Promise<{success, newVote, action}>` | Casts or retracts a vote | | `clearVotes` | `() => void` | Clears all votes from state and `localStorage` | ### `toggleVote` Logic - **Same direction** → removes the existing vote (`action: 'remove'`) - **Opposite direction** → fires a remove for the old vote first, then adds the new one (keeps server totals consistent) - **Optimistic update** → state updates immediately; reverts on API failure ## Usage Example ```typescript import { useRoadmapVoting } from './use-roadmap-voting'; function RoadmapCard({ taskId }: { taskId: string }) { const { getVote, toggleVote, isLoading } = useRoadmapVoting({ voteApiEndpoint: '/proxy/roadmap/vote', storageKey: 'roadmap_votes_v1_main', }); const currentVote = getVote(taskId); const handleVote = async (direction: 'up' | 'down') => { const { success, newVote } = await toggleVote(taskId, direction); if (!success) console.warn('Vote failed, reverted to:', newVote); }; if (isLoading) return null; return (