Skip to content

Commit 4f8071b

Browse files
committed
fix: reconcile duplicated merge content in TaskCard and socketClient
TaskCard.tsx and socketClient.ts (plus their test files) each contained two full, unreconciled implementations concatenated together from a bad merge of three feature branches, breaking tsc/build entirely (fixes #153). TaskCard now combines all three shipped behaviors in one component: - URL-param status/priority filtering (useTaskFilters + TaskFilters) - Optimistic "verify quality" vote with pending state and rollback-on-failure toast (submitVote) - Background on-chain-event auto-verify with a completion pulse animation (useTaskChainEvents + useChainState forceSync + useEvents) - Completed tasks still sort to the bottom (statusSortWeight) The superseded synchronous "instant complete on click" action (handleVote) from the older single-guardian design is dropped in favor of the richer optimistic multi-guardian vote flow; the two tests that only exercised that removed action were removed along with it, and one filtering test's render call was aligned to use the same ToastProvider wrapper as the rest of the suite (TaskCard now unconditionally calls useToast()). socketClient.ts is reduced to one coherent module-level singleton (previously connectSocket, notifyStatus, updateAuthToken, onSocketStatus, emitSocketEvent, and resetSocketClientForTests were each declared twice with conflicting signatures). updateAuthToken now unconditionally reconnects instead of gating on socket.connected, to actually satisfy its own test. useSocketIO.ts had the same duplicate-import and duplicate-statement symptoms; deduplicated with no behavior change.
1 parent 77225a7 commit 4f8071b

6 files changed

Lines changed: 73 additions & 314 deletions

File tree

src/components/TaskCard.tsx

Lines changed: 62 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
'use client';
22

33
import { useCallback, useEffect, useMemo, useState } from 'react';
4-
import { CheckCircle2, Clock, AlertCircle, ShieldCheck } from 'lucide-react';
4+
import { CheckCircle2, Clock, AlertCircle, Shield, ShieldCheck, Loader2 } from 'lucide-react';
55
import { useTranslation } from 'react-i18next';
66
import { useEvents } from '@/hooks/useEvents';
77
import { useTaskChainEvents } from '@/hooks/useTaskChainEvents';
88
import { useChainState } from '@/hooks/useChainState';
9-
import { useState, useCallback } from 'react';
10-
import { CheckCircle2, Clock, AlertCircle, ShieldCheck, Loader2 } from 'lucide-react';
11-
import { useMemo, useState, useCallback } from 'react';
12-
import { CheckCircle2, Clock, AlertCircle, Shield, Loader2 } from 'lucide-react';
13-
import { useTranslation } from 'react-i18next';
14-
import TaskFilters from './TaskFilters';
159
import { useTaskFilters } from '@/hooks/useTaskFilters';
10+
import TaskFilters from './TaskFilters';
1611
import { useToast } from '@/components/Toast';
1712

1813
export interface TaskCardTask {
@@ -90,31 +85,6 @@ function statusSortWeight(task: TaskCardTask): number {
9085
return 2;
9186
}
9287

93-
export default function TaskCard({
94-
tasks: initialTasks = mockTasks,
95-
pollIntervalMs,
96-
}: TaskCardProps) {
97-
const { t } = useTranslation();
98-
const { emit } = useEvents();
99-
const { forceSync } = useChainState({ cacheKey: 'tasks' });
100-
const { lastEvent } = useTaskChainEvents({ intervalMs: pollIntervalMs });
101-
102-
const [taskList, setTaskList] = useState<TaskCardTask[]>(initialTasks);
103-
const [completedIds, setCompletedIds] = useState<Set<string>>(
104-
() => new Set(initialTasks.filter((t) => t.is_done || t.status === 'completed').map((t) => t.id)),
105-
);
106-
const [animatingId, setAnimatingId] = useState<string | null>(null);
107-
108-
const sortedTasks = useMemo(
109-
() => [...taskList].sort((a, b) => statusSortWeight(a) - statusSortWeight(b)),
110-
[taskList],
111-
);
112-
113-
useEffect(() => {
114-
if (initialTasks !== mockTasks) {
115-
setTaskList(initialTasks);
116-
}
117-
}, [initialTasks]);
11888
function delay(ms: number): Promise<void> {
11989
return new Promise((r) => setTimeout(r, ms));
12090
}
@@ -134,18 +104,67 @@ export function matchesFilter(task: TaskCardTask, status: string, priority: stri
134104
return true;
135105
}
136106

137-
export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmitVote }: TaskCardProps) {
107+
export default function TaskCard({
108+
tasks: initialTasks = mockTasks,
109+
pollIntervalMs,
110+
submitVote = defaultSubmitVote,
111+
}: TaskCardProps) {
138112
const { t } = useTranslation();
113+
const { emit } = useEvents();
114+
const { forceSync } = useChainState({ cacheKey: 'tasks' });
115+
const { lastEvent } = useTaskChainEvents({ intervalMs: pollIntervalMs });
139116
const { filters } = useTaskFilters();
140117
const { showToast } = useToast();
141118

119+
const [taskList, setTaskList] = useState<TaskCardTask[]>(initialTasks);
120+
const [animatingId, setAnimatingId] = useState<string | null>(null);
142121
const [pendingVotes, setPendingVotes] = useState<Record<string, boolean>>({});
143122
const [optimisticVotes, setOptimisticVotes] = useState<Record<string, number>>({});
144123
const [optimisticStatus, setOptimisticStatus] = useState<Record<string, TaskCardTask['status']>>({});
145124

125+
useEffect(() => {
126+
if (initialTasks !== mockTasks) {
127+
setTaskList(initialTasks);
128+
}
129+
}, [initialTasks]);
130+
131+
// Auto-verify: when the chain-event poller reports a task was verified
132+
// on-chain, mark it completed locally and pulse a completion animation.
133+
useEffect(() => {
134+
if (!lastEvent) return;
135+
const { taskId, taskTitle } = lastEvent;
136+
137+
setTaskList((prev) => {
138+
const existing = prev.find((task) => task.id === taskId);
139+
if (!existing || existing.is_done || existing.status === 'completed') return prev;
140+
return prev.map((task) =>
141+
task.id === taskId ? { ...task, status: 'completed', is_done: true } : task,
142+
);
143+
});
144+
145+
setAnimatingId(taskId);
146+
const timer = setTimeout(() => setAnimatingId(null), 600);
147+
148+
emit({
149+
type: 'task_verified',
150+
actor: 'system',
151+
resource: taskTitle,
152+
resourceId: taskId,
153+
metadata: { taskId },
154+
});
155+
forceSync(['tasks', `task:${taskId}`]);
156+
157+
return () => clearTimeout(timer);
158+
}, [lastEvent, emit, forceSync]);
159+
146160
const filteredTasks = useMemo(
147-
() => tasks.filter((task) => matchesFilter(task, filters.status, filters.priority)),
148-
[tasks, filters.status, filters.priority],
161+
() => taskList.filter((task) => matchesFilter(task, filters.status, filters.priority)),
162+
[taskList, filters.status, filters.priority],
163+
);
164+
165+
const sortedTasks = useMemo(
166+
() => [...filteredTasks].sort((a, b) => statusSortWeight(a) - statusSortWeight(b)),
167+
[filteredTasks],
149168
);
150169

151170
const handleVerify = useCallback(
@@ -154,7 +173,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
154173
if (pendingVotes[taskId]) return;
155174

156175
const prevStatus = optimisticStatus[taskId] ?? task.status;
157-
const prevVotes = task.votes ?? 0;
158176
const optimisticDelta = optimisticVotes[taskId] ?? 0;
159177

160178
setPendingVotes((p) => ({ ...p, [taskId]: true }));
@@ -188,47 +206,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
188206
[pendingVotes, optimisticVotes, optimisticStatus, submitVote, showToast, t],
189207
);
190208

191-
useEffect(() => {
192-
if (!lastEvent) return;
193-
const { taskId } = lastEvent;
194-
setTaskList((prev) => {
195-
const existing = prev.find((t) => t.id === taskId);
196-
if (!existing || existing.is_done || existing.status === 'completed') return prev;
197-
return prev.map((t) =>
198-
t.id === taskId ? { ...t, status: 'completed', is_done: true } : t,
199-
);
200-
});
201-
setCompletedIds((prev) => new Set(prev).add(taskId));
202-
setAnimatingId(taskId);
203-
const timer = setTimeout(() => setAnimatingId(null), 600);
204-
return () => clearTimeout(timer);
205-
}, [lastEvent]);
206-
207-
const handleVote = useCallback(
208-
(task: TaskCardTask) => {
209-
const title = task.title ?? t(task.titleKey ?? '');
210-
setTaskList((prev) =>
211-
prev.map((t) =>
212-
t.id === task.id ? { ...t, status: 'completed', is_done: true } : t,
213-
),
214-
);
215-
setCompletedIds((prev) => new Set(prev).add(task.id));
216-
setAnimatingId(task.id);
217-
setTimeout(() => setAnimatingId(null), 600);
218-
219-
emit({
220-
type: 'task_verified',
221-
actor: 'guardian',
222-
resource: title,
223-
resourceId: task.id,
224-
metadata: { taskId: task.id, reward: task.reward },
225-
});
226-
227-
forceSync(['tasks', `task:${task.id}`]);
228-
},
229-
[emit, forceSync, t],
230-
);
231-
232209
const getStatusIcon = (status: TaskCardTask['status'], isAnimating: boolean) => {
233210
if (isAnimating) {
234211
return <ShieldCheck className="w-5 h-5 text-emerald-600 dark:text-emerald-400 animate-bounce" aria-hidden="true" />;
@@ -270,36 +247,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
270247
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('tasks.heading')}</h2>
271248
</div>
272249

273-
<div className="space-y-3">
274-
{sortedTasks.map((task) => {
275-
const status = task.is_done ? 'completed' : task.status;
276-
const title = task.title ?? t(task.titleKey ?? '');
277-
const canVote = !task.is_done && status !== 'completed';
278-
const isAnimating = animatingId === task.id;
279-
{tasks.map((task) => {
280-
const baseStatus = task.is_done ? 'completed' : task.status;
281-
const status = optimisticStatus[task.id] ?? baseStatus;
282-
const title = task.title ?? t(task.titleKey ?? '');
283-
const isPending = pendingVotes[task.id] ?? false;
284-
const canVote = !task.is_done && status !== 'completed' && !isPending;
285-
const voteCount = (task.votes ?? 0) + (optimisticVotes[task.id] ?? 0);
286-
287-
return (
288-
<div
289-
key={task.id}
290-
className={`bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-4 shadow-sm transition-all duration-500 ${
291-
isAnimating
292-
? 'border-emerald-400 dark:border-emerald-500 scale-[1.02] animate-in zoom-in-95 fade-in'
293-
: 'hover:border-slate-300 dark:hover:border-slate-600'
294-
}`}
295-
>
296-
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
297-
<div className="flex items-start gap-3 flex-1 w-full">
298-
{getStatusIcon(status, isAnimating)}
299-
<div className="flex-1">
300-
<div className="flex items-center gap-2">
301-
<h3 className="font-medium text-slate-900 dark:text-white">{title}</h3>
302-
{getPriorityBadge(task.priority)}
303250
<TaskFilters />
304251

305252
{filteredTasks.length === 0 ? (
@@ -308,22 +255,27 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
308255
</p>
309256
) : (
310257
<div className="space-y-3">
311-
{filteredTasks.map((task) => {
258+
{sortedTasks.map((task) => {
312259
const baseStatus = task.is_done ? 'completed' : task.status;
313260
const status = optimisticStatus[task.id] ?? baseStatus;
314261
const title = task.title ?? t(task.titleKey ?? '');
315262
const isPending = pendingVotes[task.id] ?? false;
316263
const canVote = !task.is_done && status !== 'completed' && !isPending;
317264
const voteCount = (task.votes ?? 0) + (optimisticVotes[task.id] ?? 0);
265+
const isAnimating = animatingId === task.id;
318266

319267
return (
320268
<div
321269
key={task.id}
322-
className="bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-slate-300 dark:hover:border-slate-600 transition-colors shadow-sm"
270+
className={`bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl p-4 shadow-sm transition-all duration-500 ${
271+
isAnimating
272+
? 'border-emerald-400 dark:border-emerald-500 scale-[1.02] animate-in zoom-in-95 fade-in'
273+
: 'hover:border-slate-300 dark:hover:border-slate-600'
274+
}`}
323275
>
324276
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
325277
<div className="flex items-start gap-3 flex-1 w-full">
326-
{getStatusIcon(status)}
278+
{getStatusIcon(status, isAnimating)}
327279
<div className="flex-1">
328280
<div className="flex items-center gap-2">
329281
<h3 className="font-medium text-slate-900 dark:text-white">{title}</h3>
@@ -371,31 +323,6 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
371323
) : null}
372324
</div>
373325
</div>
374-
<div className="text-right space-y-2">
375-
<span className="block text-lg font-semibold text-indigo-600 dark:text-indigo-400">{task.reward}</span>
376-
{isPending ? (
377-
<button
378-
type="button"
379-
disabled
380-
className="rounded-lg bg-slate-100 dark:bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-600 cursor-wait transition-colors flex items-center gap-2"
381-
>
382-
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
383-
{t('tasks.verify.pending')}
384-
</button>
385-
) : canVote ? (
386-
<button
387-
type="button"
388-
onClick={() => handleVote(task)}
389-
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900 active:scale-95"
390-
aria-label={`Vote for ${title}`}
391-
onClick={() => handleVerify(task)}
392-
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
393-
aria-label={`Verify quality for ${title}`}
394-
>
395-
{t('tasks.verify.action')}
396-
</button>
397-
) : null}
398-
</div>
399326
</div>
400327
);
401328
})}

src/components/__tests__/TaskCard.test.tsx

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import { act, render, screen } from '@testing-library/react';
2-
import userEvent from '@testing-library/user-event';
3-
4-
import TaskCard, { type TaskCardTask } from '@/components/TaskCard';
5-
import { resetChainStateForTests } from '@/hooks/useChainState';
6-
import { render, screen, fireEvent, act } from '@testing-library/react';
71
import { act, fireEvent, render, screen } from '@testing-library/react';
82

93
import TaskCard, { matchesFilter, type TaskCardTask } from '@/components/TaskCard';
4+
import { resetChainStateForTests } from '@/hooks/useChainState';
105
import { ToastProvider } from '@/components/Toast';
116

127
let _searchParams = new URLSearchParams();
@@ -74,6 +69,8 @@ describe('matchesFilter', () => {
7469
describe('TaskCard', () => {
7570
afterEach(() => {
7671
act(() => resetChainStateForTests());
72+
});
73+
7774
beforeEach(() => {
7875
_searchParams = new URLSearchParams();
7976
});
@@ -84,8 +81,7 @@ describe('TaskCard', () => {
8481
expect(screen.getByText('Review validator evidence')).toBeInTheDocument();
8582
expect(screen.getByText('25 VERO')).toBeInTheDocument();
8683
expect(
87-
screen.getByRole('button', { name: /vote for review validator evidence/i }),
88-
screen.getByRole('button', { name: /verify quality for review validator evidence/i })
84+
screen.getByRole('button', { name: /verify quality for review validator evidence/i }),
8985
).toBeInTheDocument();
9086
});
9187

@@ -178,20 +174,6 @@ describe('TaskCard', () => {
178174
expect(screen.getByText(/verification failed/i)).toBeInTheDocument();
179175
});
180176

181-
it('marks a task as completed and removes Vote button when Vote is clicked', async () => {
182-
const user = userEvent.setup();
183-
const task = createTask({ id: 'v1', title: 'Votable task' });
184-
render(<TaskCard tasks={[task]} />);
185-
186-
const voteButton = screen.getByRole('button', { name: /vote for votable task/i });
187-
expect(voteButton).toBeInTheDocument();
188-
189-
await user.click(voteButton);
190-
191-
expect(screen.queryByRole('button', { name: /vote for votable task/i })).not.toBeInTheDocument();
192-
expect(screen.getByText('completed')).toBeInTheDocument();
193-
});
194-
195177
it('sorts completed tasks to the bottom', () => {
196178
const tasks: TaskCardTask[] = [
197179
createTask({
@@ -214,24 +196,14 @@ describe('TaskCard', () => {
214196
}),
215197
];
216198

217-
render(<TaskCard tasks={tasks} />);
199+
render(<TaskCard tasks={tasks} />, { wrapper: Wrapper });
218200

219201
const cards = screen.getAllByText(/Alpha|Bravo|Charlie/);
220202
expect(cards[0]).toHaveTextContent('Charlie');
221203
expect(cards[1]).toHaveTextContent('Alpha');
222204
expect(cards[2]).toHaveTextContent('Bravo');
223205
});
224206

225-
it('highlights a task with animation classes when voted completed', async () => {
226-
const user = userEvent.setup();
227-
const task = createTask({ id: 'anim1', title: 'Animate me' });
228-
const { container } = render(<TaskCard tasks={[task]} />);
229-
230-
const voteButton = screen.getByRole('button', { name: /vote for animate me/i });
231-
await user.click(voteButton);
232-
233-
const card = container.querySelector('[class*="scale-"]');
234-
expect(card).toBeInTheDocument();
235207
it('renders filter controls', () => {
236208
render(<TaskCard tasks={[]} />, { wrapper: Wrapper });
237209

@@ -252,7 +224,7 @@ describe('TaskCard', () => {
252224
expect(screen.getByText('Completed task')).toBeInTheDocument();
253225

254226
_searchParams = new URLSearchParams();
255-
rerender(<TaskCard tasks={tasks} />, { wrapper: Wrapper });
227+
rerender(<TaskCard tasks={tasks} />);
256228
expect(screen.getByText('Pending task')).toBeInTheDocument();
257229
});
258230

0 commit comments

Comments
 (0)