From 697e82a88862d9fc26ed774b4cc82555c71cc6af Mon Sep 17 00:00:00 2001 From: sriyuthy Date: Fri, 9 Jan 2026 03:15:33 -0600 Subject: [PATCH] gameification system --- .../app/api/documents/analyze-azure/route.ts | 2 + .../client-pages/dashboard/StatsWidget.tsx | 172 ++++++++++++++++-- .../src/client-pages/tasks/TasksPage.tsx | 57 +++++- .../src/components/tasks/TaskFilters.tsx | 10 +- .../src/components/tasks/TaskList.tsx | 82 ++++++++- taskmaster-client/src/lib/gamification.ts | 112 ++++++++++++ taskmaster-client/src/services/api/index.ts | 1 + .../src/services/api/taskService.ts | 1 + .../src/services/api/userService.ts | 25 +++ 9 files changed, 429 insertions(+), 33 deletions(-) create mode 100644 taskmaster-client/src/lib/gamification.ts diff --git a/taskmaster-client/src/app/api/documents/analyze-azure/route.ts b/taskmaster-client/src/app/api/documents/analyze-azure/route.ts index 71a6e13..058f54b 100644 --- a/taskmaster-client/src/app/api/documents/analyze-azure/route.ts +++ b/taskmaster-client/src/app/api/documents/analyze-azure/route.ts @@ -4,6 +4,7 @@ import DocumentIntelligence from '@azure-rest/ai-document-intelligence'; import { AzureKeyCredential } from '@azure/core-auth'; import { getLongRunningPoller, isUnexpected } from '@azure-rest/ai-document-intelligence'; import { AzureOpenAI } from 'openai'; +import { getPointsForPriority } from '../../../../lib/gamification'; /** * Azure Document Intelligence + Foundry (GPT-5) Pipeline @@ -259,6 +260,7 @@ export async function POST(req: NextRequest) { completed: false, task_type: t.type || 'other', // Use task_type instead of topic topic: t.type || null, // Also set topic for compatibility + points: getPointsForPriority(t.priority), })); diff --git a/taskmaster-client/src/client-pages/dashboard/StatsWidget.tsx b/taskmaster-client/src/client-pages/dashboard/StatsWidget.tsx index a356c71..f33d2cd 100644 --- a/taskmaster-client/src/client-pages/dashboard/StatsWidget.tsx +++ b/taskmaster-client/src/client-pages/dashboard/StatsWidget.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from "react"; -import { CheckSquare, Zap, TrendingUp, X } from "lucide-react"; +import { CheckSquare, Trophy, Zap, TrendingUp, X } from "lucide-react"; import { apiService } from "../../services/api"; import { useUser } from "../../context/UserContext"; import type { TasksData } from "../../services/types"; +import { RANKS, getRankProgress } from "../../lib/gamification"; interface StatsWidgetProps { tasks: TasksData[]; @@ -10,10 +11,26 @@ interface StatsWidgetProps { user?: any; } +type StatItem = { + label: string; + value: string; + change: string; + icon: React.ElementType; + color: string; + onClick?: () => void; + changeClass?: string; + progress?: number; + progressLabel?: string; + progressLeftLabel?: string; + progressRightLabel?: string; + progressBarColor?: string; +}; + export const StatsWidget: React.FC = ({ tasks = [], isLoading = false, user }) => { const { user: contextUser } = useUser(); const currentUser = user || contextUser; const [showStreakModal, setShowStreakModal] = useState(false); + const [showRankModal, setShowRankModal] = useState(false); const [loginDates, setLoginDates] = useState([]); const [streak, setStreak] = useState(0); const [isLoadingStreak, setIsLoadingStreak] = useState(false); @@ -88,10 +105,38 @@ export const StatsWidget: React.FC = ({ tasks = [], isLoading const last30Days = getLast30Days(); const loginDatesSet = new Set(loginDates.map(d => new Date(d).toISOString().split('T')[0])); - const userPoints = currentUser?.points || 0; - const userLevel = currentUser?.level || 1; + const taskPoints = tasks.reduce((total, task) => { + const isComplete = task.completed || task.status === "completed"; + if (!isComplete) return total; + const points = task.earnedPoints ?? task.points ?? 0; + return total + (Number.isFinite(points) ? points : 0); + }, 0); + const userPoints = Math.max(taskPoints, currentUser?.points || 0); + const { currentRank, nextRank, progress, targetPoints, pointsToNext } = getRankProgress(userPoints); + const getRankStyle = (rankName?: string) => { + switch (rankName) { + case "Bronze": + return { text: "text-amber-700", bar: "bg-amber-700/70", chip: "bg-amber-700/10 text-amber-700 border-amber-700/30" }; + case "Silver": + return { text: "text-gray-400", bar: "bg-gray-400/70", chip: "bg-gray-400/10 text-gray-400 border-gray-400/30" }; + case "Gold": + return { text: "text-yellow-500", bar: "bg-yellow-500/70", chip: "bg-yellow-500/10 text-yellow-500 border-yellow-500/30" }; + case "Diamond": + return { text: "text-blue-400", bar: "bg-blue-400/70", chip: "bg-blue-400/10 text-blue-400 border-blue-400/30" }; + case "Study Expert": + return { text: "text-emerald-500", bar: "bg-emerald-500/70", chip: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30" }; + case "Task Master": + return { text: "text-rose-500", bar: "bg-rose-500/70", chip: "bg-rose-500/10 text-rose-500 border-rose-500/30" }; + default: + return { text: "text-yellow-500", bar: "bg-yellow-500/70", chip: "bg-yellow-500/10 text-yellow-500 border-yellow-500/30" }; + } + }; + const rankStyle = getRankStyle(currentRank.name); + const pointsValue = userPoints.toString(); + const pointsTarget = nextRank ? `${targetPoints}` : `${userPoints}`; + const modalPointsLabel = nextRank ? `${userPoints} / ${targetPoints}` : `${userPoints}`; - const stats = [ + const stats: StatItem[] = [ { label: "Due Today", value: isLoading ? "..." : dueTodayTotal.toString(), @@ -110,11 +155,16 @@ export const StatsWidget: React.FC = ({ tasks = [], isLoading }, { label: "Points", - value: isLoading ? "..." : userPoints.toString(), - change: `Level ${userLevel}`, - icon: Zap, - color: "text-yellow-500", - onClick: undefined + value: isLoading ? "..." : pointsValue, + change: `Rank ${currentRank.name}`, + icon: Trophy, + color: rankStyle.text, + onClick: () => setShowRankModal(true), + changeClass: "text-muted-foreground", + progress: isLoading ? undefined : progress, + progressLeftLabel: `${userPoints}`, + progressRightLabel: pointsTarget, + progressBarColor: rankStyle.bar, }, { label: "Streak", @@ -130,7 +180,8 @@ export const StatsWidget: React.FC = ({ tasks = [], isLoading change: dueTodayTotal > 0 ? `${dueTodayCompleted}/${dueTodayTotal} due today` : "No tasks due today", icon: TrendingUp, color: "text-purple-500", - onClick: undefined + onClick: undefined, + changeClass: dueTodayTotal > 0 ? "text-green-500" : "text-muted-foreground", }, ]; @@ -139,20 +190,43 @@ export const StatsWidget: React.FC = ({ tasks = [], isLoading
{stats.map((stat) => { const Icon = stat.icon; + const isPoints = stat.label === "Points"; return (
-
+

{stat.label}

{stat.value}

- - {stat.change} - + {isPoints && ( +
+ {stat.change} +
+ )} + {typeof stat.progress === "number" && ( +
+ {stat.progressRightLabel && ( +
+ {stat.progressRightLabel} +
+ )} +
+
+
+
+ )} + {!isPoints && ( + + {stat.change} + + )}
-
+
@@ -235,6 +309,72 @@ export const StatsWidget: React.FC = ({ tasks = [], isLoading
)} + + {/* Rank Modal */} + {showRankModal && ( +
+
+
+
+

Rank Progress

+

+ {nextRank ? `${pointsToNext} points to reach ${nextRank.name}` : "You reached the top rank"} +

+
+ +
+ +
+
+ {modalPointsLabel} points + {currentRank.name} +
+
+
+
+
+ +
+ {RANKS.map((rank, index) => { + const next = RANKS[index + 1]; + const maxPoints = next ? next.minPoints - 1 : null; + const rangeLabel = maxPoints !== null ? `${rank.minPoints} - ${maxPoints}` : `${rank.minPoints}+`; + const isCurrent = rank.name === currentRank.name; + const style = getRankStyle(rank.name); + return ( +
+
+
+ +
+
+
{rank.name}
+
{rangeLabel} pts
+
+
+ {isCurrent && ( + Current + )} +
+ ); + })} +
+
+
+ )} ); }; diff --git a/taskmaster-client/src/client-pages/tasks/TasksPage.tsx b/taskmaster-client/src/client-pages/tasks/TasksPage.tsx index 9af1729..3644fd4 100644 --- a/taskmaster-client/src/client-pages/tasks/TasksPage.tsx +++ b/taskmaster-client/src/client-pages/tasks/TasksPage.tsx @@ -14,9 +14,10 @@ import { TaskTimeline } from "../../components/tasks/TaskTimeline"; import { TaskFilters } from "../../components/tasks/TaskFilters"; import { TaskViewToggle } from "../../components/tasks/TaskViewToggle"; import type { TasksData, ClassData } from "../../services/types"; +import { calculateEarnedPoints } from "../../lib/gamification"; const TasksPage: React.FC = () => { - const { user, isLoadingUser } = useUser(); + const { user, isLoadingUser, setUserState } = useUser(); const router = useRouter(); const searchParams = useSearchParams(); const [tasks, setTasks] = useState([]); @@ -24,7 +25,7 @@ const TasksPage: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [showModal, setShowModal] = useState(false); const [editingTaskId, setEditingTaskId] = useState(null); - const [filter, setFilter] = useState<"today" | "upcoming" | "history">("today"); + const [filter, setFilter] = useState<"today" | "upcoming" | "overdue" | "unscheduled" | "history">("today"); const [viewMode, setViewMode] = useState<"list" | "timeline">("list"); const [error, setError] = useState(null); @@ -92,12 +93,37 @@ const TasksPage: React.FC = () => { }; const handleToggleComplete = async (taskId: string, completed: boolean) => { + const targetTask = tasks.find((task) => task._id === taskId); + if (!targetTask) return; + const taskPoints = Number.isFinite(targetTask.points ?? 0) ? (targetTask.points ?? 0) : 0; + const earnedPoints = completed + ? calculateEarnedPoints(taskPoints, targetTask.deadline) + : 0; + const status = completed ? 'completed' : 'pending'; + const previousTasks = tasks; + + setTasks(prev => prev.map(t => + t._id === taskId ? { ...t, completed, status, earnedPoints } : t + )); + try { - const status = completed ? 'completed' : 'pending'; - await apiService.updateTask(taskId, { completed, status }); - setTasks(prev => prev.map(t => - t._id === taskId ? { ...t, completed, status } : t - )); + await apiService.updateTask(taskId, { completed, status, earnedPoints }); + + if (taskPoints > 0 && targetTask) { + const previousEarned = Number.isFinite(targetTask.earnedPoints ?? taskPoints) + ? targetTask.earnedPoints ?? taskPoints + : taskPoints; + const pointsDelta = completed ? earnedPoints : -1 * previousEarned; + + if (pointsDelta !== 0) { + try { + const nextPoints = await apiService.updateUserPoints(pointsDelta); + setUserState({ points: nextPoints }); + } catch (pointsError) { + console.error("Error updating points:", pointsError); + } + } + } // Notify other components const { taskEvents } = await import('../../lib/taskEvents'); @@ -109,6 +135,7 @@ const TasksPage: React.FC = () => { } } catch (error: any) { console.error("Error updating task:", error); + setTasks(previousTasks); setError(error.message || "Failed to update task"); } }; @@ -131,9 +158,25 @@ const TasksPage: React.FC = () => { return date >= endOfDay; }; + const isOverdueTask = (task: TasksData) => { + const isComplete = task.completed || task.status === "completed"; + if (isComplete) return false; + if (task.status === "overdue") return true; + if (!task.deadline) return false; + const date = new Date(task.deadline); + return date < startOfDay; + }; + + const isUnscheduledTask = (task: TasksData) => { + const isComplete = task.completed || task.status === "completed"; + return !task.deadline && !isComplete; + }; + return { today: tasks.filter(t => t.deadline && isToday(t.deadline)).length, upcoming: tasks.filter(t => !t.completed && isUpcoming(t.deadline)).length, + overdue: tasks.filter(t => isOverdueTask(t)).length, + unscheduled: tasks.filter(t => isUnscheduledTask(t)).length, history: tasks.filter(t => t.completed || t.status === "completed").length, }; }, [tasks]); diff --git a/taskmaster-client/src/components/tasks/TaskFilters.tsx b/taskmaster-client/src/components/tasks/TaskFilters.tsx index b2cd00c..80c0b70 100644 --- a/taskmaster-client/src/components/tasks/TaskFilters.tsx +++ b/taskmaster-client/src/components/tasks/TaskFilters.tsx @@ -5,19 +5,23 @@ import React from 'react'; interface TaskFiltersProps { - currentFilter: "today" | "upcoming" | "history"; - onFilterChange: (filter: "today" | "upcoming" | "history") => void; + currentFilter: "today" | "upcoming" | "overdue" | "unscheduled" | "history"; + onFilterChange: (filter: "today" | "upcoming" | "overdue" | "unscheduled" | "history") => void; counts: { today: number; upcoming: number; + overdue: number; + unscheduled: number; history: number; }; } export const TaskFilters: React.FC = ({ currentFilter, onFilterChange, counts }) => { - const filters: Array<{ value: "today" | "upcoming" | "history"; label: string }> = [ + const filters: Array<{ value: "today" | "upcoming" | "overdue" | "unscheduled" | "history"; label: string }> = [ { value: "today", label: "Today" }, { value: "upcoming", label: "Upcoming" }, + { value: "overdue", label: "Overdue" }, + { value: "unscheduled", label: "Unscheduled" }, { value: "history", label: "History" }, ]; diff --git a/taskmaster-client/src/components/tasks/TaskList.tsx b/taskmaster-client/src/components/tasks/TaskList.tsx index b581256..a97aba9 100644 --- a/taskmaster-client/src/components/tasks/TaskList.tsx +++ b/taskmaster-client/src/components/tasks/TaskList.tsx @@ -1,11 +1,12 @@ import React from "react"; import { Edit2, Trash2 } from "lucide-react"; import type { TasksData, ClassData } from "../../services/types"; +import { calculateEarnedPoints } from "../../lib/gamification"; interface TaskListProps { tasks: TasksData[]; classes: ClassData[]; - filter: "today" | "upcoming" | "history"; + filter: "today" | "upcoming" | "overdue" | "unscheduled" | "history"; onEdit: (task: TasksData) => void; onDelete: (taskId: string) => void; onToggleComplete?: (taskId: string, completed: boolean) => void; @@ -21,6 +22,9 @@ export const TaskList: React.FC = ({ }) => { const [completingTaskId, setCompletingTaskId] = React.useState(null); const [selectedHistoryDate, setSelectedHistoryDate] = React.useState(null); + const [rewardedTaskId, setRewardedTaskId] = React.useState(null); + const [rewardedPoints, setRewardedPoints] = React.useState(0); + const rewardTimeoutRef = React.useRef | null>(null); const now = new Date(); const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -38,6 +42,20 @@ export const TaskList: React.FC = ({ return date >= endOfDay; }; + const isOverdue = (task: TasksData) => { + const isComplete = task.completed || task.status === "completed"; + if (isComplete) return false; + if (task.status === "overdue") return true; + if (!task.deadline) return false; + const date = new Date(task.deadline); + return date < startOfDay; + }; + + const isUnscheduled = (task: TasksData) => { + const isComplete = task.completed || task.status === "completed"; + return !task.deadline && !isComplete; + }; + const todayTasks = tasks .filter((task) => task.deadline && isToday(task.deadline)) .sort((a, b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime()); @@ -46,6 +64,14 @@ export const TaskList: React.FC = ({ .filter((task) => !task.completed && isUpcoming(task.deadline)) .sort((a, b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime()); + const overdueTasks = tasks + .filter(isOverdue) + .sort((a, b) => taskDateSortValue(a.deadline) - taskDateSortValue(b.deadline)); + + const unscheduledTasks = tasks + .filter(isUnscheduled) + .sort((a, b) => a.title.localeCompare(b.title)); + const historyTasks = tasks .filter((task) => task.completed || task.status === "completed") .sort((a, b) => { @@ -55,7 +81,25 @@ export const TaskList: React.FC = ({ }); const filteredTasks = - filter === "today" ? todayTasks : filter === "upcoming" ? upcomingTasks : historyTasks; + filter === "today" + ? todayTasks + : filter === "upcoming" + ? upcomingTasks + : filter === "overdue" + ? overdueTasks + : filter === "unscheduled" + ? unscheduledTasks + : historyTasks; + + const displayTasks = (() => { + if (!rewardedTaskId) return filteredTasks; + const rewardedTask = tasks.find((task) => task._id === rewardedTaskId); + if (!rewardedTask) return filteredTasks; + if (filteredTasks.some((task) => task._id === rewardedTask._id)) { + return filteredTasks; + } + return [rewardedTask, ...filteredTasks]; + })(); function taskDateSortValue(deadline?: string) { if (!deadline) return 0; @@ -64,11 +108,30 @@ export const TaskList: React.FC = ({ const handleToggle = async (task: TasksData) => { if (!onToggleComplete) return; + const wasComplete = task.completed || task.status === "completed"; + const points = Number.isFinite(task.points ?? 0) ? (task.points ?? 0) : 0; + const earnedPoints = calculateEarnedPoints(points, task.deadline); + if (!wasComplete && earnedPoints > 0) { + if (rewardTimeoutRef.current) { + clearTimeout(rewardTimeoutRef.current); + } + setRewardedTaskId(task._id); + setRewardedPoints(earnedPoints); + rewardTimeoutRef.current = setTimeout(() => { + setRewardedTaskId(null); + }, 1100); + } setCompletingTaskId(task._id); await onToggleComplete(task._id, !task.completed); setTimeout(() => setCompletingTaskId(null), 600); }; + React.useEffect(() => { + return () => { + if (rewardTimeoutRef.current) clearTimeout(rewardTimeoutRef.current); + }; + }, []); + React.useEffect(() => { if (filter !== "history") return; if (selectedHistoryDate) return; @@ -87,7 +150,7 @@ export const TaskList: React.FC = ({ if (next) setSelectedHistoryDate(next); }, [filter, historyTasks, selectedHistoryDate]); - if (filteredTasks.length === 0) { + if (displayTasks.length === 0) { return (

No tasks found. Create your first task!

@@ -204,12 +267,12 @@ export const TaskList: React.FC = ({ return (
- {filteredTasks.map((task) => { + {displayTasks.map((task) => { const taskClass = task.class ? classes.find((c) => c._id === task.class) : null; return (