diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 3c7a6a7d5..c4066e194 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -10,6 +10,7 @@ import DashboardSSEProvider from "@/components/DashboardSSEProvider"; import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; import ThrottleBanner from "@/components/ThrottleBanner"; import CustomizableDashboard from "@/components/dashboard/CustomizableDashboard"; +import MilestonePlanner from "@/components/MilestonePlanner"; export default async function DashboardPage() { const session = await getServerSession(authOptions); @@ -85,7 +86,9 @@ export default async function DashboardPage() { - +
+ +
diff --git a/src/components/MilestonePlanner.tsx b/src/components/MilestonePlanner.tsx new file mode 100644 index 000000000..4c1ce0bc9 --- /dev/null +++ b/src/components/MilestonePlanner.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Target, Plus, Trash2, TrendingUp, Clock, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; + +interface Milestone { + id: string; + title: string; + description: string; + targetValue: number; + currentValue: number; + unit: string; + targetDate: string; + createdAt: string; + category: 'commits' | 'streak' | 'projects' | 'custom'; +} + +type MilestoneStatus = 'completed' | 'on-track' | 'at-risk' | 'behind'; + +const CATEGORY_OPTIONS = [ + { value: 'commits', label: 'Commits', icon: '๐Ÿ“' }, + { value: 'streak', label: 'Streak Days', icon: '๐Ÿ”ฅ' }, + { value: 'projects', label: 'Projects', icon: '๐Ÿš€' }, + { value: 'custom', label: 'Custom', icon: '๐ŸŽฏ' }, +]; + +const STORAGE_KEY = 'devtrack:milestones'; + +function loadMilestones(): Milestone[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveMilestones(milestones: Milestone[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(milestones)); +} + +function getStatus(milestone: Milestone): MilestoneStatus { + const progress = milestone.currentValue / milestone.targetValue; + if (progress >= 1) return 'completed'; + + const now = Date.now(); + const created = new Date(milestone.createdAt).getTime(); + const target = new Date(milestone.targetDate).getTime(); + const totalDuration = target - created; + const elapsed = now - created; + const expectedProgress = totalDuration > 0 ? elapsed / totalDuration : 0; + + if (now > target) return 'behind'; + if (progress >= expectedProgress * 0.9) return 'on-track'; + if (progress >= expectedProgress * 0.6) return 'at-risk'; + return 'behind'; +} + +function getForecastDate(milestone: Milestone): string | null { + const { currentValue, targetValue, createdAt } = milestone; + if (currentValue <= 0) return null; + + const elapsed = Date.now() - new Date(createdAt).getTime(); + const rate = currentValue / elapsed; // units per ms + const remaining = targetValue - currentValue; + if (rate <= 0) return null; + + const msNeeded = remaining / rate; + const forecastDate = new Date(Date.now() + msNeeded); + return forecastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function StatusBadge({ status }: { status: MilestoneStatus }) { + const config = { + completed: { label: 'Completed', color: '#10b981', icon: }, + 'on-track': { label: 'On Track', color: '#6366f1', icon: }, + 'at-risk': { label: 'At Risk', color: '#f59e0b', icon: }, + behind: { label: 'Behind', color: '#ef4444', icon: }, + }[status]; + + return ( + + {config.icon} {config.label} + + ); +} + +export default function MilestonePlanner() { + const [milestones, setMilestones] = useState([]); + const [showForm, setShowForm] = useState(false); + const [expanded, setExpanded] = useState(null); + const [form, setForm] = useState({ + title: '', description: '', targetValue: '', currentValue: '0', + unit: '', targetDate: '', category: 'custom' as Milestone['category'], + }); + + useEffect(() => { + setMilestones(loadMilestones()); + }, []); + + const handleAdd = useCallback(() => { + if (!form.title || !form.targetValue || !form.targetDate) return; + const newMilestone: Milestone = { + id: `${Date.now()}`, + title: form.title, + description: form.description, + targetValue: Number(form.targetValue), + currentValue: Number(form.currentValue) || 0, + unit: form.unit || CATEGORY_OPTIONS.find(c => c.value === form.category)?.label || 'units', + targetDate: form.targetDate, + createdAt: new Date().toISOString(), + category: form.category, + }; + const updated = [...milestones, newMilestone]; + setMilestones(updated); + saveMilestones(updated); + setForm({ title: '', description: '', targetValue: '', currentValue: '0', unit: '', targetDate: '', category: 'custom' }); + setShowForm(false); + }, [form, milestones]); + + const handleIncrement = useCallback((id: string) => { + const updated = milestones.map(m => + m.id === id ? { ...m, currentValue: Math.min(m.currentValue + 1, m.targetValue) } : m + ); + setMilestones(updated); + saveMilestones(updated); + }, [milestones]); + + const handleDelete = useCallback((id: string) => { + const updated = milestones.filter(m => m.id !== id); + setMilestones(updated); + saveMilestones(updated); + }, [milestones]); + + const statusCounts = milestones.reduce((acc, m) => { + acc[getStatus(m)] = (acc[getStatus(m)] || 0) + 1; + return acc; + }, {} as Record); + + return ( +
+ {/* Header */} +
+
+ +

+ Milestone Planner +

+ {milestones.length > 0 && ( + + {milestones.length} + + )} +
+ +
+ + {/* Summary chips */} + {milestones.length > 0 && ( +
+ {Object.entries(statusCounts).map(([status, count]) => ( + + ))} +
+ )} + + {/* Add form */} + {showForm && ( +
+
+
+ + setForm(f => ({ ...f, title: e.target.value }))} + placeholder="e.g. Reach 500 commits" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem', boxSizing: 'border-box' }} + /> +
+
+ + +
+
+ + setForm(f => ({ ...f, targetDate: e.target.value }))} + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+ + setForm(f => ({ ...f, targetValue: e.target.value }))} + placeholder="100" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+ + setForm(f => ({ ...f, currentValue: e.target.value }))} + placeholder="0" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+
+ + +
+
+ )} + + {/* Milestone list */} + {milestones.length === 0 ? ( +
+ +

No milestones yet. Create one to start tracking!

+
+ ) : ( +
+ {milestones.map(m => { + const status = getStatus(m); + const pct = Math.min(Math.round((m.currentValue / m.targetValue) * 100), 100); + const forecast = getForecastDate(m); + const isExpanded = expanded === m.id; + const statusColor = { completed: '#10b981', 'on-track': '#6366f1', 'at-risk': '#f59e0b', behind: '#ef4444' }[status]; + + return ( +
+
+ {CATEGORY_OPTIONS.find(c => c.value === m.category)?.icon} +
+
+ {m.title} + +
+
+ {m.currentValue}/{m.targetValue} {m.unit} ยท Due {new Date(m.targetDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+
+
+ + + +
+
+ + {/* Progress bar */} +
+
+
+
+ {pct}% complete + {forecast && status !== 'completed' && ( + + ๐Ÿ“… Forecast: {forecast} + + )} +
+ + {/* Expanded details */} + {isExpanded && ( +
+ {m.description &&

{m.description}

} +

Created: {new Date(m.createdAt).toLocaleDateString()}

+ {status === 'completed' &&

๐ŸŽ‰ Milestone achieved!

} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} \ No newline at end of file