Skip to content

Commit 434313e

Browse files
authored
Merge pull request #33 from ArjinAlbay/claude/analyze-kanban-board-01SpdbuZU5vqEWhQ78MZTsSa
fix: prevent duplicate task IDs in Kanban columns
2 parents 355d4a1 + 12b26ef commit 434313e

File tree

3 files changed

+270
-19
lines changed

3 files changed

+270
-19
lines changed

src/app/dashboard/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Layout } from "@/components/layout/Layout";
44
import { useRequireAuth } from "@/hooks/useAuth";
55
import { PageHeader } from "@/components/layout/PageHeader";
66
import { TodoDashboard } from "@/components/widget/TodoDashboard";
7-
import { QuickWinsCounters } from "@/components/widget/QuickWinsCounters";
7+
import { QuickWinsNotifier } from "@/components/widget/QuickWinsNotifier";
88

99
export default function DashboardPage() {
1010
const { isLoading } = useRequireAuth();
@@ -24,11 +24,11 @@ export default function DashboardPage() {
2424

2525
return (
2626
<Layout>
27+
<QuickWinsNotifier />
28+
2729
<div className="max-w-7xl mx-auto p-6 space-y-6">
2830
<PageHeader />
2931

30-
<QuickWinsCounters />
31-
3232
<TodoDashboard />
3333
</div>
3434
</Layout>
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
import Link from "next/link";
5+
import { useQuickWinsStore } from "@/stores/quickWins";
6+
import { Button } from "@/components/ui/button";
7+
import { Badge } from "@/components/ui/badge";
8+
import { Sparkles, Wrench, X, ChevronRight, ExternalLink } from "lucide-react";
9+
import { toast } from "sonner";
10+
import { cn } from "@/lib/utils";
11+
12+
export function QuickWinsNotifier() {
13+
const { goodIssues, easyFixes, loading, error, fetchGoodIssues, fetchEasyFixes } =
14+
useQuickWinsStore();
15+
16+
const [isExpanded, setIsExpanded] = useState(false);
17+
const [hasNewItems, setHasNewItems] = useState(false);
18+
const [previousTotal, setPreviousTotal] = useState(0);
19+
const [showPulse, setShowPulse] = useState(false);
20+
21+
const totalCount = goodIssues.length + easyFixes.length;
22+
const hasItems = totalCount > 0;
23+
24+
useEffect(() => {
25+
fetchGoodIssues();
26+
fetchEasyFixes();
27+
}, [fetchGoodIssues, fetchEasyFixes]);
28+
29+
useEffect(() => {
30+
if (totalCount > previousTotal && previousTotal > 0) {
31+
setHasNewItems(true);
32+
setShowPulse(true);
33+
34+
const newCount = totalCount - previousTotal;
35+
toast.success("🎉 New opportunities ready!", {
36+
description: `${newCount} fresh ${newCount === 1 ? 'item' : 'items'} just arrived`,
37+
duration: 5000,
38+
});
39+
40+
setTimeout(() => {
41+
setIsExpanded(true);
42+
}, 500);
43+
44+
setTimeout(() => setShowPulse(false), 3000);
45+
}
46+
setPreviousTotal(totalCount);
47+
}, [totalCount, previousTotal]);
48+
49+
const handleDismissNew = useCallback(() => {
50+
setHasNewItems(false);
51+
}, []);
52+
53+
if (!hasItems && !loading.goodIssues && !loading.easyFixes) {
54+
return null;
55+
}
56+
57+
return (
58+
<>
59+
<div className="fixed top-20 right-6 z-40">
60+
<div
61+
className={cn(
62+
"relative transition-all duration-300",
63+
showPulse && "animate-bounce"
64+
)}
65+
>
66+
<Button
67+
onClick={() => setIsExpanded(!isExpanded)}
68+
className={cn(
69+
"group relative shadow-lg transition-all duration-300",
70+
hasNewItems && "ring-2 ring-green-500 ring-offset-2",
71+
isExpanded && "rounded-b-none"
72+
)}
73+
variant={hasNewItems ? "default" : "secondary"}
74+
size="sm"
75+
>
76+
<div className="flex items-center gap-2">
77+
<Sparkles className={cn(
78+
"h-4 w-4 transition-transform",
79+
showPulse && "animate-spin"
80+
)} />
81+
<span className="font-medium">Quick Wins</span>
82+
{totalCount > 0 && (
83+
<Badge variant="outline" className="ml-1 bg-background">
84+
{totalCount}
85+
</Badge>
86+
)}
87+
{hasNewItems && (
88+
<span className="absolute -top-1 -right-1 flex h-3 w-3">
89+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
90+
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
91+
</span>
92+
)}
93+
</div>
94+
</Button>
95+
</div>
96+
97+
<div
98+
className={cn(
99+
"absolute top-full right-0 mt-0 w-80 bg-background border rounded-b-lg shadow-xl transition-all duration-300 origin-top",
100+
isExpanded ? "scale-y-100 opacity-100" : "scale-y-0 opacity-0 pointer-events-none"
101+
)}
102+
>
103+
<div className="p-4 space-y-3">
104+
{hasNewItems && (
105+
<div className="flex items-center justify-between p-2 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
106+
<div className="flex items-center gap-2">
107+
<Sparkles className="h-4 w-4 text-green-600 dark:text-green-400" />
108+
<span className="text-sm font-medium text-green-700 dark:text-green-300">
109+
Fresh opportunities!
110+
</span>
111+
</div>
112+
<button
113+
onClick={handleDismissNew}
114+
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
115+
>
116+
<X className="h-4 w-4" />
117+
</button>
118+
</div>
119+
)}
120+
121+
<Link
122+
href="/quick-wins"
123+
onClick={() => setIsExpanded(false)}
124+
className="block group"
125+
>
126+
<div className="p-3 rounded-lg border bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950 dark:to-emerald-950 hover:shadow-md transition-all">
127+
<div className="flex items-center justify-between mb-2">
128+
<div className="flex items-center gap-2">
129+
<Sparkles className="h-4 w-4 text-green-600 dark:text-green-400" />
130+
<span className="font-semibold text-sm text-green-900 dark:text-green-100">
131+
Good First Issues
132+
</span>
133+
</div>
134+
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 group-hover:translate-x-1 transition-transform" />
135+
</div>
136+
{loading.goodIssues ? (
137+
<div className="flex items-center gap-2">
138+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
139+
<span className="text-xs text-muted-foreground">Loading...</span>
140+
</div>
141+
) : error.goodIssues ? (
142+
<span className="text-xs text-red-600 dark:text-red-400">
143+
Failed to load
144+
</span>
145+
) : (
146+
<div className="flex items-baseline gap-2">
147+
<span className="text-2xl font-bold text-green-600 dark:text-green-400">
148+
{goodIssues.length}
149+
</span>
150+
<span className="text-xs text-muted-foreground">
151+
perfect for new contributors
152+
</span>
153+
</div>
154+
)}
155+
</div>
156+
</Link>
157+
158+
<Link
159+
href="/quick-wins"
160+
onClick={() => setIsExpanded(false)}
161+
className="block group"
162+
>
163+
<div className="p-3 rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950 dark:to-indigo-950 hover:shadow-md transition-all">
164+
<div className="flex items-center justify-between mb-2">
165+
<div className="flex items-center gap-2">
166+
<Wrench className="h-4 w-4 text-blue-600 dark:text-blue-400" />
167+
<span className="font-semibold text-sm text-blue-900 dark:text-blue-100">
168+
Easy Fixes
169+
</span>
170+
</div>
171+
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 group-hover:translate-x-1 transition-transform" />
172+
</div>
173+
{loading.easyFixes ? (
174+
<div className="flex items-center gap-2">
175+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
176+
<span className="text-xs text-muted-foreground">Loading...</span>
177+
</div>
178+
) : error.easyFixes ? (
179+
<span className="text-xs text-red-600 dark:text-red-400">
180+
Failed to load
181+
</span>
182+
) : (
183+
<div className="flex items-baseline gap-2">
184+
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
185+
{easyFixes.length}
186+
</span>
187+
<span className="text-xs text-muted-foreground">
188+
quick contributions to make
189+
</span>
190+
</div>
191+
)}
192+
</div>
193+
</Link>
194+
195+
<Link
196+
href="/quick-wins"
197+
onClick={() => setIsExpanded(false)}
198+
className="block"
199+
>
200+
<Button variant="outline" className="w-full" size="sm">
201+
<span>View All Quick Wins</span>
202+
<ExternalLink className="h-3 w-3 ml-2" />
203+
</Button>
204+
</Link>
205+
</div>
206+
</div>
207+
</div>
208+
209+
{isExpanded && (
210+
<div
211+
className="fixed inset-0 z-30"
212+
onClick={() => setIsExpanded(false)}
213+
/>
214+
)}
215+
</>
216+
);
217+
}

src/stores/kanban.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -380,10 +380,13 @@ export const useKanbanStore = create<KanbanState>()(
380380
}
381381

382382
if (updatedColumns[targetColumn]) {
383-
updatedColumns[targetColumn] = {
384-
...updatedColumns[targetColumn],
385-
taskIds: [...updatedColumns[targetColumn].taskIds, task.id],
386-
};
383+
const currentTaskIds = updatedColumns[targetColumn].taskIds;
384+
if (!currentTaskIds.includes(task.id)) {
385+
updatedColumns[targetColumn] = {
386+
...updatedColumns[targetColumn],
387+
taskIds: [...currentTaskIds, task.id],
388+
};
389+
}
387390
}
388391
});
389392

@@ -432,16 +435,23 @@ export const useKanbanStore = create<KanbanState>()(
432435
],
433436
};
434437

435-
set((state) => ({
436-
tasks: { ...state.tasks, [id]: task },
437-
columns: {
438-
...state.columns,
439-
[columnId || "todo"]: {
440-
...state.columns[columnId || "todo"],
441-
taskIds: [...state.columns[columnId || "todo"].taskIds, id],
438+
set((state) => {
439+
const targetColumn = columnId || "todo";
440+
const currentTaskIds = state.columns[targetColumn].taskIds;
441+
const updatedTaskIds = currentTaskIds.includes(id)
442+
? currentTaskIds
443+
: [...currentTaskIds, id];
444+
return {
445+
tasks: { ...state.tasks, [id]: task },
446+
columns: {
447+
...state.columns,
448+
[targetColumn]: {
449+
...state.columns[targetColumn],
450+
taskIds: updatedTaskIds,
451+
},
442452
},
443-
},
444-
}));
453+
};
454+
});
445455

446456
return id;
447457
},
@@ -475,13 +485,17 @@ export const useKanbanStore = create<KanbanState>()(
475485
set((state) => {
476486
const newAddedIds = new Set(state.addedActionItemIds);
477487
newAddedIds.add(item.id.toString());
488+
const currentTaskIds = state.columns[columnId].taskIds;
489+
const updatedTaskIds = currentTaskIds.includes(id)
490+
? currentTaskIds
491+
: [...currentTaskIds, id];
478492
return {
479493
tasks: { ...state.tasks, [id]: task },
480494
columns: {
481495
...state.columns,
482496
[columnId]: {
483497
...state.columns[columnId],
484-
taskIds: [...state.columns[columnId].taskIds, id],
498+
taskIds: updatedTaskIds,
485499
},
486500
},
487501
addedActionItemIds: newAddedIds,
@@ -704,6 +718,11 @@ export const useKanbanStore = create<KanbanState>()(
704718
const newArchivedTasks = { ...state.archivedTasks };
705719
delete newArchivedTasks[taskId];
706720

721+
const currentTodoTaskIds = state.columns.todo.taskIds;
722+
const updatedTodoTaskIds = currentTodoTaskIds.includes(taskId)
723+
? currentTodoTaskIds
724+
: [...currentTodoTaskIds, taskId];
725+
707726
return {
708727
tasks: {
709728
...state.tasks,
@@ -713,7 +732,7 @@ export const useKanbanStore = create<KanbanState>()(
713732
...state.columns,
714733
todo: {
715734
...state.columns.todo,
716-
taskIds: [...state.columns.todo.taskIds, taskId],
735+
taskIds: updatedTodoTaskIds,
717736
},
718737
},
719738
archivedTasks: newArchivedTasks,
@@ -898,9 +917,11 @@ export const useKanbanStore = create<KanbanState>()(
898917
}
899918

900919
if (newColumns[toColumnId]) {
920+
const currentTaskIds = newColumns[toColumnId].taskIds;
921+
const newTaskIds = taskIds.filter((id) => !currentTaskIds.includes(id));
901922
newColumns[toColumnId] = {
902923
...newColumns[toColumnId],
903-
taskIds: [...newColumns[toColumnId].taskIds, ...taskIds],
924+
taskIds: [...currentTaskIds, ...newTaskIds],
904925
};
905926
}
906927

@@ -1078,6 +1099,19 @@ export const useKanbanStore = create<KanbanState>()(
10781099
} else if (!state.addedActionItemIds) {
10791100
state.addedActionItemIds = new Set();
10801101
}
1102+
1103+
if (state.columns) {
1104+
Object.keys(state.columns).forEach((columnId) => {
1105+
const column = state.columns[columnId];
1106+
if (column?.taskIds) {
1107+
const uniqueTaskIds = [...new Set(column.taskIds)];
1108+
if (uniqueTaskIds.length !== column.taskIds.length) {
1109+
console.warn(`Deduplicating column ${columnId}: ${column.taskIds.length} -> ${uniqueTaskIds.length}`);
1110+
column.taskIds = uniqueTaskIds;
1111+
}
1112+
}
1113+
});
1114+
}
10811115
},
10821116
}
10831117
)

0 commit comments

Comments
 (0)