Skip to content

Commit 082bd81

Browse files
committed
feat: implement Phase 5 - Analytics & Dashboard
Backend Changes: - Added getDueDateAnalytics endpoint to analytics API - Created comprehensive due date analytics repository method - Added TypeBox schemas for due date analytics response - Exposed new endpoint at /api/v1/analytics/board/:boardId/due-dates Frontend Changes: - Created useDueDateAnalytics hook for fetching due date statistics - Built OverdueCardsWidget component with priority breakdown - Built UpcomingDueDatesWidget component with summary cards - Added overdue badge to BoardView header - Enhanced Dashboard with due date widgets in 2-column layout Features: - Real-time overdue count display in board header - Detailed overdue cards list with priority indicators - Upcoming due dates summary (today, this week, future) - Due date analytics by priority (critical, high, medium, low) - Interactive card navigation from widgets - Responsive grid layout for dashboard widgets Analytics Data: - Overdue cards count and breakdown by priority - Cards due today with details - Cards due this week count - Future upcoming cards count - Cards with no due date count
1 parent 02bcc48 commit 082bd81

File tree

10 files changed

+579
-10
lines changed

10 files changed

+579
-10
lines changed

api/src/modules/analytics/analytics.controller.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,24 @@ export class AnalyticsController {
133133
return reply.code(500).send({ error: "Internal server error" });
134134
}
135135
}
136+
137+
/**
138+
* GET /api/v1/analytics/board/:boardId/due-dates
139+
* Get due date analytics for a board
140+
*/
141+
async getDueDateAnalytics(
142+
request: FastifyRequest<{
143+
Params: BoardAnalyticsParams;
144+
}>,
145+
reply: FastifyReply
146+
) {
147+
try {
148+
const { boardId } = request.params;
149+
const analytics = await this.service.getDueDateAnalytics(boardId);
150+
return reply.code(200).send(analytics);
151+
} catch (error) {
152+
request.log.error(error);
153+
return reply.code(500).send({ error: "Internal server error" });
154+
}
155+
}
136156
}

api/src/modules/analytics/analytics.repository.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,136 @@ export class AnalyticsRepository {
304304
.groupBy("sprints.id", "sprints.name", "sprints.start_date", "sprints.end_date", "sprints.status")
305305
.orderBy("sprints.start_date", "desc");
306306
}
307+
308+
/**
309+
* Get detailed due date analytics for a board
310+
*/
311+
async getDueDateAnalytics(boardId: string) {
312+
const now = this.knex.raw("NOW()");
313+
314+
// Get overdue cards count
315+
const overdueCards = await this.knex("cards")
316+
.join("lists", "cards.list_id", "lists.id")
317+
.where("lists.board_id", boardId)
318+
.whereNotNull("cards.due_date")
319+
.where("cards.due_date", "<", now)
320+
.whereNot("cards.status", "completed")
321+
.count("* as count")
322+
.first();
323+
324+
// Get cards due today
325+
const dueToday = await this.knex("cards")
326+
.join("lists", "cards.list_id", "lists.id")
327+
.where("lists.board_id", boardId)
328+
.whereNotNull("cards.due_date")
329+
.whereRaw("DATE(cards.due_date) = CURRENT_DATE")
330+
.whereNot("cards.status", "completed")
331+
.count("* as count")
332+
.first();
333+
334+
// Get cards due this week
335+
const dueThisWeek = await this.knex("cards")
336+
.join("lists", "cards.list_id", "lists.id")
337+
.where("lists.board_id", boardId)
338+
.whereNotNull("cards.due_date")
339+
.where("cards.due_date", ">=", now)
340+
.where("cards.due_date", "<=", this.knex.raw("NOW() + INTERVAL '7 days'"))
341+
.whereNot("cards.status", "completed")
342+
.count("* as count")
343+
.first();
344+
345+
// Get upcoming cards (beyond this week)
346+
const upcoming = await this.knex("cards")
347+
.join("lists", "cards.list_id", "lists.id")
348+
.where("lists.board_id", boardId)
349+
.whereNotNull("cards.due_date")
350+
.where("cards.due_date", ">", this.knex.raw("NOW() + INTERVAL '7 days'"))
351+
.whereNot("cards.status", "completed")
352+
.count("* as count")
353+
.first();
354+
355+
// Get cards with no due date
356+
const noDueDate = await this.knex("cards")
357+
.join("lists", "cards.list_id", "lists.id")
358+
.where("lists.board_id", boardId)
359+
.whereNull("cards.due_date")
360+
.whereNot("cards.status", "completed")
361+
.count("* as count")
362+
.first();
363+
364+
// Get breakdown by priority
365+
const byPriority = await this.knex("cards")
366+
.join("lists", "cards.list_id", "lists.id")
367+
.where("lists.board_id", boardId)
368+
.whereNotNull("cards.due_date")
369+
.where("cards.due_date", "<", now)
370+
.whereNot("cards.status", "completed")
371+
.select("cards.priority")
372+
.count("* as count")
373+
.groupBy("cards.priority");
374+
375+
// Get overdue cards list
376+
const overdueCardsList = await this.knex("cards")
377+
.select(
378+
"cards.id",
379+
"cards.title",
380+
"cards.due_date",
381+
"cards.priority",
382+
"cards.status",
383+
"lists.id as list_id",
384+
"lists.title as list_title"
385+
)
386+
.join("lists", "cards.list_id", "lists.id")
387+
.where("lists.board_id", boardId)
388+
.whereNotNull("cards.due_date")
389+
.where("cards.due_date", "<", now)
390+
.whereNot("cards.status", "completed")
391+
.orderBy("cards.due_date", "asc")
392+
.limit(20);
393+
394+
// Get cards due today list
395+
const dueTodayList = await this.knex("cards")
396+
.select(
397+
"cards.id",
398+
"cards.title",
399+
"cards.due_date",
400+
"cards.priority",
401+
"cards.status",
402+
"lists.id as list_id",
403+
"lists.title as list_title"
404+
)
405+
.join("lists", "cards.list_id", "lists.id")
406+
.where("lists.board_id", boardId)
407+
.whereNotNull("cards.due_date")
408+
.whereRaw("DATE(cards.due_date) = CURRENT_DATE")
409+
.whereNot("cards.status", "completed")
410+
.orderBy("cards.due_date", "asc")
411+
.limit(20);
412+
413+
return {
414+
summary: {
415+
overdue: parseInt(overdueCards?.count as string) || 0,
416+
dueToday: parseInt(dueToday?.count as string) || 0,
417+
dueThisWeek: parseInt(dueThisWeek?.count as string) || 0,
418+
upcoming: parseInt(upcoming?.count as string) || 0,
419+
noDueDate: parseInt(noDueDate?.count as string) || 0,
420+
},
421+
byPriority: {
422+
critical: byPriority.find((p) => p.priority === "critical")
423+
? parseInt(byPriority.find((p) => p.priority === "critical")!.count as string)
424+
: 0,
425+
high: byPriority.find((p) => p.priority === "high")
426+
? parseInt(byPriority.find((p) => p.priority === "high")!.count as string)
427+
: 0,
428+
medium: byPriority.find((p) => p.priority === "medium")
429+
? parseInt(byPriority.find((p) => p.priority === "medium")!.count as string)
430+
: 0,
431+
low: byPriority.find((p) => p.priority === "low")
432+
? parseInt(byPriority.find((p) => p.priority === "low")!.count as string)
433+
: 0,
434+
},
435+
overdueCards: overdueCardsList,
436+
dueTodayCards: dueTodayList,
437+
};
438+
}
307439
}

api/src/modules/analytics/analytics.route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
SprintBurndownResponseSchema,
1111
VelocityResponseSchema,
1212
AssignedTaskSchema,
13+
DueDateAnalyticsResponseSchema,
1314
} from "./analytics.schema";
1415

1516
export default async function analyticsRoutes(fastify: FastifyInstance) {
@@ -95,4 +96,20 @@ export default async function analyticsRoutes(fastify: FastifyInstance) {
9596
},
9697
controller.getAssignedTasks.bind(controller)
9798
);
99+
100+
// Get due date analytics
101+
fastify.get(
102+
"/board/:boardId/due-dates",
103+
{
104+
schema: {
105+
description: "Get due date analytics for a board",
106+
tags: ["Analytics"],
107+
params: BoardAnalyticsParamsSchema,
108+
response: {
109+
200: DueDateAnalyticsResponseSchema,
110+
},
111+
},
112+
},
113+
controller.getDueDateAnalytics.bind(controller)
114+
);
98115
}

api/src/modules/analytics/analytics.schema.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,44 @@ export const VelocityMetricSchema = Type.Object({
142142

143143
export const VelocityResponseSchema = Type.Array(VelocityMetricSchema);
144144

145+
// Due Date Analytics Schemas
146+
export const DueDateCardSchema = Type.Object({
147+
id: Type.String({ format: "uuid" }),
148+
title: Type.String(),
149+
due_date: Type.Union([Type.String(), Type.Null()]),
150+
priority: Type.Union([
151+
Type.Literal("low"),
152+
Type.Literal("medium"),
153+
Type.Literal("high"),
154+
Type.Literal("critical"),
155+
]),
156+
status: Type.Union([Type.String(), Type.Null()]),
157+
list_id: Type.String({ format: "uuid" }),
158+
list_title: Type.String(),
159+
});
160+
161+
export const DueDateSummarySchema = Type.Object({
162+
overdue: Type.Number(),
163+
dueToday: Type.Number(),
164+
dueThisWeek: Type.Number(),
165+
upcoming: Type.Number(),
166+
noDueDate: Type.Number(),
167+
});
168+
169+
export const DueDateByPrioritySchema = Type.Object({
170+
critical: Type.Number(),
171+
high: Type.Number(),
172+
medium: Type.Number(),
173+
low: Type.Number(),
174+
});
175+
176+
export const DueDateAnalyticsResponseSchema = Type.Object({
177+
summary: DueDateSummarySchema,
178+
byPriority: DueDateByPrioritySchema,
179+
overdueCards: Type.Array(DueDateCardSchema),
180+
dueTodayCards: Type.Array(DueDateCardSchema),
181+
});
182+
145183
// Type exports
146184
export type PersonalDashboardQuery = Static<typeof PersonalDashboardQuerySchema>;
147185
export type BoardAnalyticsParams = Static<typeof BoardAnalyticsParamsSchema>;
@@ -150,6 +188,7 @@ export type PersonalDashboardResponse = Static<typeof PersonalDashboardResponseS
150188
export type BoardAnalyticsResponse = Static<typeof BoardAnalyticsResponseSchema>;
151189
export type SprintBurndownResponse = Static<typeof SprintBurndownResponseSchema>;
152190
export type VelocityResponse = Static<typeof VelocityResponseSchema>;
191+
export type DueDateAnalyticsResponse = Static<typeof DueDateAnalyticsResponseSchema>;
153192

154193
// Schema collection for registration
155194
export const AnalyticsSchema = {
@@ -160,4 +199,5 @@ export const AnalyticsSchema = {
160199
BoardAnalyticsResponseSchema,
161200
SprintBurndownResponseSchema,
162201
VelocityResponseSchema,
202+
DueDateAnalyticsResponseSchema,
163203
};

api/src/modules/analytics/analytics.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,11 @@ export class AnalyticsService {
6262
async getUserAssignedTasks(userId: string, organizationId: string) {
6363
return this.repository.getAssignedTasks(userId, organizationId);
6464
}
65+
66+
/**
67+
* Get due date analytics for a board
68+
*/
69+
async getDueDateAnalytics(boardId: string) {
70+
return this.repository.getDueDateAnalytics(boardId);
71+
}
6572
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useDueDateAnalytics, type DueDateCard } from "@/hooks/useAnalytics";
2+
import { AlertCircle, Calendar } from "lucide-react";
3+
import { formatDueDateDisplay } from "@/lib/dateUtils";
4+
import { useNavigate } from "react-router-dom";
5+
import { Badge } from "@/components/ui/badge";
6+
7+
interface OverdueCardsWidgetProps {
8+
boardId: string | null;
9+
}
10+
11+
export const OverdueCardsWidget = ({ boardId }: OverdueCardsWidgetProps) => {
12+
const { data: analytics, isLoading } = useDueDateAnalytics(boardId);
13+
const navigate = useNavigate();
14+
15+
if (!boardId || isLoading) {
16+
return (
17+
<div className="bg-white rounded-lg shadow p-6">
18+
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
19+
<AlertCircle className="w-5 h-5 text-red-600" />
20+
Overdue Cards
21+
</h2>
22+
{isLoading && (
23+
<div className="flex items-center justify-center h-32">
24+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
25+
</div>
26+
)}
27+
</div>
28+
);
29+
}
30+
31+
if (!analytics || analytics.summary.overdue === 0) {
32+
return (
33+
<div className="bg-white rounded-lg shadow p-6">
34+
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
35+
<AlertCircle className="w-5 h-5 text-green-600" />
36+
Overdue Cards
37+
</h2>
38+
<div className="text-center py-8">
39+
<p className="text-gray-500">No overdue cards! Great job! 🎉</p>
40+
</div>
41+
</div>
42+
);
43+
}
44+
45+
const getPriorityColor = (priority: string) => {
46+
switch (priority) {
47+
case "critical":
48+
return "bg-red-100 text-red-800";
49+
case "high":
50+
return "bg-orange-100 text-orange-800";
51+
case "medium":
52+
return "bg-blue-100 text-blue-800";
53+
case "low":
54+
return "bg-green-100 text-green-800";
55+
default:
56+
return "bg-gray-100 text-gray-800";
57+
}
58+
};
59+
60+
const handleCardClick = (card: DueDateCard) => {
61+
navigate(`/board/${boardId}`);
62+
};
63+
64+
return (
65+
<div className="bg-white rounded-lg shadow p-6">
66+
<div className="flex items-center justify-between mb-6">
67+
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
68+
<AlertCircle className="w-5 h-5 text-red-600" />
69+
Overdue Cards
70+
</h2>
71+
<div className="flex items-center gap-4">
72+
<div className="text-right">
73+
<p className="text-3xl font-bold text-red-600">{analytics.summary.overdue}</p>
74+
<p className="text-xs text-gray-500">Total Overdue</p>
75+
</div>
76+
</div>
77+
</div>
78+
79+
{/* Priority Breakdown */}
80+
<div className="grid grid-cols-4 gap-3 mb-6">
81+
<div className="text-center p-3 bg-red-50 rounded-lg">
82+
<p className="text-2xl font-bold text-red-600">{analytics.byPriority.critical}</p>
83+
<p className="text-xs text-gray-600">Critical</p>
84+
</div>
85+
<div className="text-center p-3 bg-orange-50 rounded-lg">
86+
<p className="text-2xl font-bold text-orange-600">{analytics.byPriority.high}</p>
87+
<p className="text-xs text-gray-600">High</p>
88+
</div>
89+
<div className="text-center p-3 bg-blue-50 rounded-lg">
90+
<p className="text-2xl font-bold text-blue-600">{analytics.byPriority.medium}</p>
91+
<p className="text-xs text-gray-600">Medium</p>
92+
</div>
93+
<div className="text-center p-3 bg-green-50 rounded-lg">
94+
<p className="text-2xl font-bold text-green-600">{analytics.byPriority.low}</p>
95+
<p className="text-xs text-gray-600">Low</p>
96+
</div>
97+
</div>
98+
99+
{/* Overdue Cards List */}
100+
<div className="space-y-3 max-h-96 overflow-y-auto">
101+
{analytics.overdueCards.map((card) => (
102+
<div
103+
key={card.id}
104+
onClick={() => handleCardClick(card)}
105+
className="p-4 border border-red-200 bg-red-50/50 rounded-lg hover:bg-red-100/50 transition cursor-pointer"
106+
>
107+
<div className="flex items-start justify-between">
108+
<div className="flex-1 min-w-0">
109+
<div className="flex items-center gap-2 mb-2">
110+
<h3 className="text-sm font-medium text-gray-900 truncate">{card.title}</h3>
111+
<Badge variant="destructive" className={`text-xs ${getPriorityColor(card.priority)}`}>
112+
{card.priority}
113+
</Badge>
114+
</div>
115+
<p className="text-xs text-gray-600 mb-1">{card.list_title}</p>
116+
{card.due_date && (
117+
<div className="flex items-center gap-1 text-xs text-red-600">
118+
<Calendar className="w-3 h-3" />
119+
<span>Due: {formatDueDateDisplay(card.due_date)}</span>
120+
</div>
121+
)}
122+
</div>
123+
</div>
124+
</div>
125+
))}
126+
</div>
127+
128+
{analytics.overdueCards.length === 0 && (
129+
<div className="text-center py-8 text-gray-500">
130+
<p>No overdue cards to display</p>
131+
</div>
132+
)}
133+
</div>
134+
);
135+
};

0 commit comments

Comments
 (0)