This commit is contained in:
@@ -12,53 +12,61 @@ vi.mock('next/navigation', () => ({
|
|||||||
|
|
||||||
// Mock ResizeObserver
|
// Mock ResizeObserver
|
||||||
global.ResizeObserver = class ResizeObserver {
|
global.ResizeObserver = class ResizeObserver {
|
||||||
observe() {}
|
observe(): void {
|
||||||
unobserve() {}
|
/* noop */
|
||||||
disconnect() {}
|
}
|
||||||
|
unobserve(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
disconnect(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
const createTestQueryClient = () => new QueryClient({
|
const createTestQueryClient = () =>
|
||||||
defaultOptions: {
|
new QueryClient({
|
||||||
queries: {
|
defaultOptions: {
|
||||||
retry: false,
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Dashboard', () => {
|
describe('Dashboard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Mock Auth Response
|
// Mock Auth Response
|
||||||
(global.fetch as any).mockImplementation((url: string) => {
|
vi.mocked(global.fetch).mockImplementation((url: string | URL | Request) => {
|
||||||
if (url === '/api/auth') {
|
if (url === '/api/auth') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
|
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
|
||||||
});
|
} as Response);
|
||||||
}
|
}
|
||||||
if (url === '/api/habits') {
|
if (url === '/api/habits') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({
|
json: () =>
|
||||||
habits: [
|
Promise.resolve({
|
||||||
{
|
habits: [
|
||||||
id: 1,
|
{
|
||||||
name: 'Test Habit',
|
id: 1,
|
||||||
type: 'neutral',
|
name: 'Test Habit',
|
||||||
totalLogs: 5,
|
type: 'neutral',
|
||||||
logsLastWeek: 2,
|
totalLogs: 5,
|
||||||
logsLastMonth: 5,
|
logsLastWeek: 2,
|
||||||
createdAt: new Date().toISOString(),
|
logsLastMonth: 5,
|
||||||
lastLoggedAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
lastLoggedAt: new Date().toISOString(),
|
||||||
]
|
},
|
||||||
}),
|
],
|
||||||
});
|
}),
|
||||||
|
} as Response);
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ describe('Dashboard', () => {
|
|||||||
render(
|
render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -78,7 +86,7 @@ describe('Dashboard', () => {
|
|||||||
render(
|
render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -99,7 +107,7 @@ describe('Dashboard', () => {
|
|||||||
render(
|
render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ interface HabitResponse {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
||||||
const [newHabitName, setNewHabitName] = useState('');
|
const [newHabitName, setNewHabitName] = useState('');
|
||||||
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
||||||
|
|
||||||
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
|
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
|
||||||
const [editHabitName, setEditHabitName] = useState('');
|
const [editHabitName, setEditHabitName] = useState('');
|
||||||
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
||||||
@@ -109,7 +109,9 @@ export default function Dashboard() {
|
|||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setCurrentTime(Date.now());
|
setCurrentTime(Date.now());
|
||||||
}, 60000);
|
}, 60000);
|
||||||
return () => clearInterval(interval);
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch habits
|
// Fetch habits
|
||||||
@@ -140,9 +142,9 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Undo log mutation
|
// Undo log mutation
|
||||||
const undoLogMutation = useMutation<void, Error, number>({
|
const undoLogMutation = useMutation<unknown, Error, number>({
|
||||||
mutationFn: async (habitId: number): Promise<void> => {
|
mutationFn: async (habitId: number): Promise<void> => {
|
||||||
const res = await fetch(`/api/habits/${habitId}/log`, {
|
const res = await fetch(`/api/habits/${String(habitId)}/log`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to undo log');
|
if (!res.ok) throw new Error('Failed to undo log');
|
||||||
@@ -172,9 +174,13 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update habit mutation
|
// Update habit mutation
|
||||||
const updateHabitMutation = useMutation<HabitResponse, Error, { id: number; name: string; type: string }>({
|
const updateHabitMutation = useMutation<
|
||||||
|
HabitResponse,
|
||||||
|
Error,
|
||||||
|
{ id: number; name: string; type: string }
|
||||||
|
>({
|
||||||
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
|
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
|
||||||
const res = await fetch(`/api/habits/${id}`, {
|
const res = await fetch(`/api/habits/${String(id)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, type }),
|
body: JSON.stringify({ name, type }),
|
||||||
@@ -189,9 +195,9 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete habit mutation
|
// Delete habit mutation
|
||||||
const deleteHabitMutation = useMutation<void, Error, number>({
|
const deleteHabitMutation = useMutation<unknown, Error, number>({
|
||||||
mutationFn: async (id: number): Promise<void> => {
|
mutationFn: async (id: number): Promise<void> => {
|
||||||
const res = await fetch(`/api/habits/${id}`, {
|
const res = await fetch(`/api/habits/${String(id)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to delete habit');
|
if (!res.ok) throw new Error('Failed to delete habit');
|
||||||
@@ -450,8 +456,10 @@ export default function Dashboard() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
|
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
|
||||||
onClick={(e) => openEditDialog(e, habit)}
|
onClick={(e) => {
|
||||||
data-testid={`edit-habit-${habit.id}`}
|
openEditDialog(e, habit);
|
||||||
|
}}
|
||||||
|
data-testid={`edit-habit-${String(habit.id)}`}
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -463,34 +471,34 @@ export default function Dashboard() {
|
|||||||
{/* Last logged */}
|
{/* Last logged */}
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 text-zinc-500" />
|
<Clock className="h-4 w-4 text-zinc-500" />
|
||||||
<span className="text-zinc-400">
|
<span className="text-zinc-400">
|
||||||
{habit.lastLoggedAt
|
{habit.lastLoggedAt
|
||||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||||
: 'Never logged'}
|
: 'Never logged'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{habit.lastLoggedAt && (
|
{habit.lastLoggedAt && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 text-zinc-500 hover:text-red-400 hover:bg-red-950/30"
|
className="h-6 w-6 text-zinc-500 hover:bg-red-950/30 hover:text-red-400"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
undoLogMutation.mutate(habit.id);
|
undoLogMutation.mutate(habit.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Undo last log</p>
|
<p>Undo last log</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -553,9 +561,14 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Habit Dialog */}
|
{/* Edit Habit Dialog */}
|
||||||
<Dialog open={!!editingHabit} onOpenChange={(open) => !open && setEditingHabit(null)}>
|
<Dialog
|
||||||
|
open={!!editingHabit}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditingHabit(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="border-zinc-800 bg-zinc-950">
|
<DialogContent className="border-zinc-800 bg-zinc-950">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Habit</DialogTitle>
|
<DialogTitle>Edit Habit</DialogTitle>
|
||||||
@@ -569,7 +582,9 @@ export default function Dashboard() {
|
|||||||
<Input
|
<Input
|
||||||
id="edit-name"
|
id="edit-name"
|
||||||
value={editHabitName}
|
value={editHabitName}
|
||||||
onChange={(e) => setEditHabitName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setEditHabitName(e.target.value);
|
||||||
|
}}
|
||||||
className="border-zinc-800 bg-zinc-900"
|
className="border-zinc-800 bg-zinc-900"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -608,47 +623,56 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
|
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
|
||||||
<div className="flex flex-1 justify-start">
|
<div className="flex flex-1 justify-start">
|
||||||
{showDeleteConfirm ? (
|
{showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDeleteHabit}
|
onClick={handleDeleteHabit}
|
||||||
disabled={deleteHabitMutation.isPending}
|
disabled={deleteHabitMutation.isPending}
|
||||||
>
|
>
|
||||||
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
|
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => setShowDeleteConfirm(false)}>
|
<Button
|
||||||
Cancel
|
variant="ghost"
|
||||||
</Button>
|
onClick={() => {
|
||||||
</div>
|
setShowDeleteConfirm(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
|
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Archive Habit
|
Archive Habit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setEditingHabit(null)}
|
onClick={() => {
|
||||||
>
|
setEditingHabit(null);
|
||||||
Cancel
|
}}
|
||||||
</Button>
|
>
|
||||||
<Button
|
Cancel
|
||||||
onClick={handleUpdateHabit}
|
</Button>
|
||||||
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
|
<Button
|
||||||
className="bg-emerald-600 hover:bg-emerald-700"
|
onClick={handleUpdateHabit}
|
||||||
>
|
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
|
||||||
<Save className="mr-2 h-4 w-4" />
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
|
>
|
||||||
</Button>
|
<Save className="mr-2 h-4 w-4" />
|
||||||
</div>
|
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user