Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^14.6.1",
"@types/cytoscape": "^3.21.7",
"@types/d3-scale": "^4.0.9",
"@types/d3-scale-chromatic": "^3.1.0",
Expand Down
4 changes: 4 additions & 0 deletions src/components/EventMonitor/eventMonitorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
LogOut,
RefreshCw,
ShieldAlert,
ShieldCheck,
ThumbsUp,
TrendingUp,
Wallet,
} from 'lucide-react';
Expand All @@ -23,6 +25,8 @@ export const EVENT_TYPE_OPTIONS: EventTypeOption[] = [
{ type: 'all', labelKey: 'eventMonitor.all', icon: Timer, color: 'text-slate-600 dark:text-slate-400' },
{ type: 'vote', labelKey: 'eventMonitor.typeVote', icon: ClipboardCheck, color: 'text-indigo-600 dark:text-indigo-400' },
{ type: 'task_registered', labelKey: 'eventMonitor.typeTask', icon: ClipboardCheck, color: 'text-emerald-600 dark:text-emerald-400' },
{ type: 'task_verified', labelKey: 'eventMonitor.typeTaskVerified', icon: ShieldCheck, color: 'text-teal-600 dark:text-teal-400' },
{ type: 'task_completed', labelKey: 'eventMonitor.typeTaskCompleted', icon: ThumbsUp, color: 'text-emerald-600 dark:text-emerald-400' },
{ type: 'reputation_change', labelKey: 'eventMonitor.typeReputation', icon: TrendingUp, color: 'text-amber-600 dark:text-amber-400' },
{ type: 'wallet_connected', labelKey: 'eventMonitor.typeWalletConnected', icon: Wallet, color: 'text-green-600 dark:text-green-400' },
{ type: 'wallet_disconnected', labelKey: 'eventMonitor.typeWalletDisconnected', icon: LogOut, color: 'text-red-600 dark:text-red-400' },
Expand Down
143 changes: 142 additions & 1 deletion src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Clock, AlertCircle, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useEvents } from '@/hooks/useEvents';
import { useTaskChainEvents } from '@/hooks/useTaskChainEvents';
import { useChainState } from '@/hooks/useChainState';
import { useState, useCallback } from 'react';
import { CheckCircle2, Clock, AlertCircle, ShieldCheck, Loader2 } from 'lucide-react';
import { useMemo, useState, useCallback } from 'react';
import { CheckCircle2, Clock, AlertCircle, Shield, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
Expand All @@ -20,6 +28,8 @@ export interface TaskCardTask {

interface TaskCardProps {
tasks?: TaskCardTask[];
pollIntervalMs?: number;
/** Override the simulated Soroban submit for testing. */
submitVote?: (taskId: string) => Promise<{ status: string; txHash?: string }>;
}

Expand Down Expand Up @@ -73,6 +83,38 @@ const mockTasks: TaskCardTask[] = [
},
];

function statusSortWeight(task: TaskCardTask): number {
const s = task.is_done ? 'completed' : task.status;
if (s === 'completed') return 3;
if (s === 'in-progress') return 1;
return 2;
}

export default function TaskCard({
tasks: initialTasks = mockTasks,
pollIntervalMs,
}: TaskCardProps) {
const { t } = useTranslation();
const { emit } = useEvents();
const { forceSync } = useChainState({ cacheKey: 'tasks' });
const { lastEvent } = useTaskChainEvents({ intervalMs: pollIntervalMs });

const [taskList, setTaskList] = useState<TaskCardTask[]>(initialTasks);
const [completedIds, setCompletedIds] = useState<Set<string>>(
() => new Set(initialTasks.filter((t) => t.is_done || t.status === 'completed').map((t) => t.id)),
);
const [animatingId, setAnimatingId] = useState<string | null>(null);

const sortedTasks = useMemo(
() => [...taskList].sort((a, b) => statusSortWeight(a) - statusSortWeight(b)),
[taskList],
);

useEffect(() => {
if (initialTasks !== mockTasks) {
setTaskList(initialTasks);
}
}, [initialTasks]);
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
Expand Down Expand Up @@ -146,7 +188,51 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
[pendingVotes, optimisticVotes, optimisticStatus, submitVote, showToast, t],
);

const getStatusIcon = (status: TaskCardTask['status']) => {
useEffect(() => {
if (!lastEvent) return;
const { taskId } = lastEvent;
setTaskList((prev) => {
const existing = prev.find((t) => t.id === taskId);
if (!existing || existing.is_done || existing.status === 'completed') return prev;
return prev.map((t) =>
t.id === taskId ? { ...t, status: 'completed', is_done: true } : t,
);
});
setCompletedIds((prev) => new Set(prev).add(taskId));
setAnimatingId(taskId);
const timer = setTimeout(() => setAnimatingId(null), 600);
return () => clearTimeout(timer);
}, [lastEvent]);

const handleVote = useCallback(
(task: TaskCardTask) => {
const title = task.title ?? t(task.titleKey ?? '');
setTaskList((prev) =>
prev.map((t) =>
t.id === task.id ? { ...t, status: 'completed', is_done: true } : t,
),
);
setCompletedIds((prev) => new Set(prev).add(task.id));
setAnimatingId(task.id);
setTimeout(() => setAnimatingId(null), 600);

emit({
type: 'task_verified',
actor: 'guardian',
resource: title,
resourceId: task.id,
metadata: { taskId: task.id, reward: task.reward },
});

forceSync(['tasks', `task:${task.id}`]);
},
[emit, forceSync, t],
);

const getStatusIcon = (status: TaskCardTask['status'], isAnimating: boolean) => {
if (isAnimating) {
return <ShieldCheck className="w-5 h-5 text-emerald-600 dark:text-emerald-400 animate-bounce" aria-hidden="true" />;
}
switch (status) {
case 'completed':
return <CheckCircle2 className="w-5 h-5 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />;
Expand Down Expand Up @@ -184,6 +270,36 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('tasks.heading')}</h2>
</div>

<div className="space-y-3">
{sortedTasks.map((task) => {
const status = task.is_done ? 'completed' : task.status;
const title = task.title ?? t(task.titleKey ?? '');
const canVote = !task.is_done && status !== 'completed';
const isAnimating = animatingId === task.id;
{tasks.map((task) => {
const baseStatus = task.is_done ? 'completed' : task.status;
const status = optimisticStatus[task.id] ?? baseStatus;
const title = task.title ?? t(task.titleKey ?? '');
const isPending = pendingVotes[task.id] ?? false;
const canVote = !task.is_done && status !== 'completed' && !isPending;
const voteCount = (task.votes ?? 0) + (optimisticVotes[task.id] ?? 0);

return (
<div
key={task.id}
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 ${
isAnimating
? 'border-emerald-400 dark:border-emerald-500 scale-[1.02] animate-in zoom-in-95 fade-in'
: 'hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 w-full">
{getStatusIcon(status, isAnimating)}
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-slate-900 dark:text-white">{title}</h3>
{getPriorityBadge(task.priority)}
<TaskFilters />

{filteredTasks.length === 0 ? (
Expand Down Expand Up @@ -255,6 +371,31 @@ export default function TaskCard({ tasks = mockTasks, submitVote = defaultSubmit
) : null}
</div>
</div>
<div className="text-right space-y-2">
<span className="block text-lg font-semibold text-indigo-600 dark:text-indigo-400">{task.reward}</span>
{isPending ? (
<button
type="button"
disabled
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"
>
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
{t('tasks.verify.pending')}
</button>
) : canVote ? (
<button
type="button"
onClick={() => handleVote(task)}
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"
aria-label={`Vote for ${title}`}
onClick={() => handleVerify(task)}
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"
aria-label={`Verify quality for ${title}`}
>
{t('tasks.verify.action')}
</button>
) : null}
</div>
</div>
);
})}
Expand Down
63 changes: 63 additions & 0 deletions src/components/__tests__/TaskCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import TaskCard, { type TaskCardTask } from '@/components/TaskCard';
import { resetChainStateForTests } from '@/hooks/useChainState';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';

import TaskCard, { matchesFilter, type TaskCardTask } from '@/components/TaskCard';
Expand Down Expand Up @@ -66,6 +72,8 @@ describe('matchesFilter', () => {
});

describe('TaskCard', () => {
afterEach(() => {
act(() => resetChainStateForTests());
beforeEach(() => {
_searchParams = new URLSearchParams();
});
Expand All @@ -76,6 +84,7 @@ describe('TaskCard', () => {
expect(screen.getByText('Review validator evidence')).toBeInTheDocument();
expect(screen.getByText('25 VERO')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /vote for review validator evidence/i }),
screen.getByRole('button', { name: /verify quality for review validator evidence/i })
).toBeInTheDocument();
});
Expand Down Expand Up @@ -169,6 +178,60 @@ describe('TaskCard', () => {
expect(screen.getByText(/verification failed/i)).toBeInTheDocument();
});

it('marks a task as completed and removes Vote button when Vote is clicked', async () => {
const user = userEvent.setup();
const task = createTask({ id: 'v1', title: 'Votable task' });
render(<TaskCard tasks={[task]} />);

const voteButton = screen.getByRole('button', { name: /vote for votable task/i });
expect(voteButton).toBeInTheDocument();

await user.click(voteButton);

expect(screen.queryByRole('button', { name: /vote for votable task/i })).not.toBeInTheDocument();
expect(screen.getByText('completed')).toBeInTheDocument();
});

it('sorts completed tasks to the bottom', () => {
const tasks: TaskCardTask[] = [
createTask({
id: 'a',
title: 'Alpha pending',
status: 'pending',
is_done: false,
}),
createTask({
id: 'b',
title: 'Bravo completed',
status: 'completed',
is_done: true,
}),
createTask({
id: 'c',
title: 'Charlie in-progress',
status: 'in-progress',
is_done: false,
}),
];

render(<TaskCard tasks={tasks} />);

const cards = screen.getAllByText(/Alpha|Bravo|Charlie/);
expect(cards[0]).toHaveTextContent('Charlie');
expect(cards[1]).toHaveTextContent('Alpha');
expect(cards[2]).toHaveTextContent('Bravo');
});

it('highlights a task with animation classes when voted completed', async () => {
const user = userEvent.setup();
const task = createTask({ id: 'anim1', title: 'Animate me' });
const { container } = render(<TaskCard tasks={[task]} />);

const voteButton = screen.getByRole('button', { name: /vote for animate me/i });
await user.click(voteButton);

const card = container.querySelector('[class*="scale-"]');
expect(card).toBeInTheDocument();
it('renders filter controls', () => {
render(<TaskCard tasks={[]} />, { wrapper: Wrapper });

Expand Down
Loading
Loading