diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 0ed963e64..ca379d1e8 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -20,6 +20,7 @@ interface Goal { created_at: string; goal_reset_version: number; is_public: boolean; + category: string | null; } interface GoalHistory { @@ -34,6 +35,7 @@ interface GoalHistory { type Recurrence = "none" | "weekly" | "monthly"; const VALID_RECURRENCES = ["none", "weekly", "monthly"] as const; +const VALID_CATEGORIES = ["Side Project", "Work", "DSA", "Open Source"] as const; const MAX_TITLE_LEN = 100; const MAX_UNIT_LEN = 30; const MIN_TARGET = 1; @@ -201,7 +203,7 @@ try { return Response.json({ error: "Invalid request body" }, { status: 400 }); } - const { title, target, unit, recurrence, deadline } = body as Record; + const { title, target, unit, recurrence, deadline, category } = body as Record; if (typeof title !== "string" || title.trim().length === 0) { return Response.json({ error: "title must be a non-empty string" }, { status: 400 }); @@ -239,6 +241,11 @@ try { } } + const safeCategory = + typeof category === "string" && VALID_CATEGORIES.includes(category as any) + ? category + : null; + const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); @@ -287,6 +294,7 @@ try { deadline: safeDeadline, current: 0, goal_reset_version: 0, + category: safeCategory, }) .select() .single(); diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 387821320..77683c61a 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -31,6 +31,7 @@ interface Goal { achieved: number; completed: boolean; } | null; + category?: string | null; } const RECURRENCE_LABELS: Record = { @@ -39,6 +40,15 @@ const RECURRENCE_LABELS: Record = { monthly: "Monthly", }; +export const CATEGORIES = ["Side Project", "Work", "DSA", "Open Source"]; + +export const CATEGORY_COLORS: Record = { + "Side Project": "bg-purple-500/10 text-purple-500 border-purple-500/30", + "Work": "bg-blue-500/10 text-blue-500 border-blue-500/30", + "DSA": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30", + "Open Source": "bg-amber-500/10 text-amber-500 border-amber-500/30", +}; + export function useGoalTracker() { const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); @@ -51,6 +61,7 @@ export function useGoalTracker() { const [unit, setUnit] = useState("commits"); const [recurrence, setRecurrence] = useState("none"); const [deadline, setDeadline] = useState(""); + const [category, setCategory] = useState(""); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [confirmingId, setConfirmingId] = useState(null); @@ -161,7 +172,7 @@ export function useGoalTracker() { try { const result = await submitGoalWithRefresh({ - payload: { title, target, unit, recurrence, deadline: deadline || null }, + payload: { title, target, unit, recurrence, deadline: deadline || null, category: category || null }, handleSync, loadGoals, }); @@ -176,6 +187,7 @@ export function useGoalTracker() { setUnit("commits"); setRecurrence("none"); setDeadline(""); + setCategory(""); if (unit === "commits" || unit === "prs") { await handleSync(); @@ -293,6 +305,8 @@ export function useGoalTracker() { setRecurrence, deadline, setDeadline, + category, + setCategory, creating, createError, confirmingId, @@ -331,6 +345,8 @@ export default function GoalTracker() { setRecurrence, deadline, setDeadline, + category, + setCategory, creating, createError, confirmingId, @@ -347,6 +363,8 @@ export default function GoalTracker() { const { setSummary, setIsUpdating } = useDashboardWidgetA11y("goal-tracker"); + const [filterCategory, setFilterCategory] = useState("All"); + useEffect(() => { setIsUpdating(loading); }, [loading, setIsUpdating]); @@ -520,6 +538,35 @@ export default function GoalTracker() { )} + {/* Filter Toggle Pills */} + {goals.length > 0 && ( +
+ + {CATEGORIES.map((cat) => ( + + ))} +
+ )} + {goals.length === 0 ? (
- {goals.map((goal) => { + {goals + .filter((goal) => filterCategory === "All" || goal.category === filterCategory) + .map((goal) => { const pct = goal.current > 0 && goal.target > 0 ? Math.max( @@ -580,6 +629,13 @@ export default function GoalTracker() { {RECURRENCE_LABELS[goal.recurrence]} )} + {goal.category && ( + + {goal.category} + + )} {isAutoSynced && ( +
+ + +
+ {(unit === "commits" || unit === "prs") && (

⚡ This goal will auto-update from your GitHub activity. diff --git a/src/components/ProjectMilestones.tsx b/src/components/ProjectMilestones.tsx new file mode 100644 index 000000000..6d729d686 --- /dev/null +++ b/src/components/ProjectMilestones.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Target, Plus, Trash2, CheckCircle2, Circle } from 'lucide-react'; +import { Milestone, Task } from '@/types/project-milestone'; + +export default function ProjectMilestones() { + const [milestones, setMilestones] = useState([]); + const [tasks, setTasks] = useState([]); + const [isClient, setIsClient] = useState(false); + + const [showMilestoneForm, setShowMilestoneForm] = useState(false); + const [milestoneForm, setMilestoneForm] = useState({ name: '', description: '', dueDate: '' }); + + const [taskInputs, setTaskInputs] = useState>({}); + + useEffect(() => { + setIsClient(true); + const storedMilestones = localStorage.getItem('devtrack_project_milestones'); + const storedTasks = localStorage.getItem('devtrack_project_tasks'); + if (storedMilestones) setMilestones(JSON.parse(storedMilestones)); + if (storedTasks) setTasks(JSON.parse(storedTasks)); + }, []); + + useEffect(() => { + if (isClient) { + localStorage.setItem('devtrack_project_milestones', JSON.stringify(milestones)); + localStorage.setItem('devtrack_project_tasks', JSON.stringify(tasks)); + } + }, [milestones, tasks, isClient]); + + if (!isClient) return null; + + const handleCreateMilestone = () => { + if (!milestoneForm.name || !milestoneForm.dueDate) return; + + const newMilestone: Milestone = { + id: crypto.randomUUID(), + name: milestoneForm.name, + description: milestoneForm.description, + dueDate: milestoneForm.dueDate, + taskIds: [], + }; + + setMilestones(prev => [newMilestone, ...prev]); + setMilestoneForm({ name: '', description: '', dueDate: '' }); + setShowMilestoneForm(false); + }; + + const handleDeleteMilestone = (id: string) => { + setMilestones(prev => prev.filter(m => m.id !== id)); + }; + + const handleAddTask = (milestoneId: string) => { + const title = taskInputs[milestoneId]?.trim(); + if (!title) return; + + const newTask: Task = { + id: crypto.randomUUID(), + title, + completed: false + }; + + setTasks(prev => [...prev, newTask]); + setMilestones(prev => prev.map(m => { + if (m.id === milestoneId) { + return { ...m, taskIds: [...m.taskIds, newTask.id] }; + } + return m; + })); + + setTaskInputs(prev => ({ ...prev, [milestoneId]: '' })); + }; + + const handleToggleTask = (taskId: string) => { + setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: !t.completed } : t)); + }; + + const handleDeleteTask = (milestoneId: string, taskId: string) => { + setTasks(prev => prev.filter(t => t.id !== taskId)); + setMilestones(prev => prev.map(m => { + if (m.id === milestoneId) { + return { ...m, taskIds: m.taskIds.filter(id => id !== taskId) }; + } + return m; + })); + }; + + return ( +

+
+
+ +

+ Project Milestones +

+
+ +
+ + {showMilestoneForm && ( +
+
+
+ + setMilestoneForm(f => ({ ...f, name: e.target.value }))} + placeholder="e.g. v1.0 Release" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+ + setMilestoneForm(f => ({ ...f, description: e.target.value }))} + placeholder="Optional description" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+ + setMilestoneForm(f => ({ ...f, dueDate: 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' }} + /> +
+
+
+ + +
+
+ )} + + {milestones.length === 0 ? ( +
+ +

No project milestones yet. Create one to start tracking!

+
+ ) : ( +
+ {milestones.map(m => { + const mTasks = m.taskIds.map(id => tasks.find(t => t.id === id)).filter((t): t is Task => !!t); + const totalTasks = mTasks.length; + const completedTasks = mTasks.filter(t => t.completed).length; + const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + return ( +
+
+
+

{m.name}

+ {m.description &&

{m.description}

} + Due: {new Date(m.dueDate).toLocaleDateString()} +
+ +
+ +
+
+ Progress + {progress}% ({completedTasks}/{totalTasks}) +
+
+
+
+
+ +
+

Tasks

+ {mTasks.length === 0 ? ( +

No tasks linked.

+ ) : ( +
    + {mTasks.map(task => ( +
  • +
    handleToggleTask(task.id)}> + {task.completed ? : } + {task.title} +
    + +
  • + ))} +
+ )} + +
+ setTaskInputs(prev => ({ ...prev, [m.id]: e.target.value }))} + onKeyDown={e => { + if (e.key === 'Enter') handleAddTask(m.id); + }} + style={{ flex: 1, padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.8rem' }} + /> + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/dashboard/CustomizableDashboard.tsx b/src/components/dashboard/CustomizableDashboard.tsx index 67f829a33..7ffe81cd0 100644 --- a/src/components/dashboard/CustomizableDashboard.tsx +++ b/src/components/dashboard/CustomizableDashboard.tsx @@ -218,6 +218,11 @@ const SponsorAnalytics = dynamic( { ssr: false, loading: () => }, ); +const ProjectMilestones = dynamic( + () => import("@/components/ProjectMilestones"), + { ssr: false, loading: () => }, +); + const SECTION_ANCHOR_IDS: Record = { overview: "overview", activity: "streaks", @@ -253,6 +258,7 @@ const WIDGET_SPAN_CLASSES: Partial> = { "daily-note": "xl:col-span-2", "recent-activity": "xl:col-span-2", "sponsor-analytics": "xl:col-span-2", + "project-milestones": "xl:col-span-2", }; const isDashboardWidgetId = ( @@ -470,6 +476,13 @@ const renderDashboardWidget = (widgetId: DashboardWidgetId): ReactNode => { ); + case "project-milestones": + return ( + + + + ); + default: return null; } diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 3f072e201..fe42e76e4 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -40,7 +40,8 @@ export type DashboardWidgetId = | "ci-analytics" | "language-breakdown" | "friend-comparison" - | "achievement-progress"; + | "achievement-progress" + | "project-milestones"; export interface DashboardLayoutPreference { version: 1; @@ -98,6 +99,7 @@ export const DASHBOARD_WIDGET_LABELS: Record = { "language-breakdown": "Language Breakdown", "friend-comparison": "Friend Comparison", "achievement-progress": "Achievement Progress", + "project-milestones": "Project Milestones", }; export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { @@ -139,6 +141,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { "language-breakdown", "friend-comparison", "achievement-progress", + "project-milestones", ], }, hidden: [], diff --git a/src/lib/goal-tracker.ts b/src/lib/goal-tracker.ts index ec6215c71..6d6c4bba4 100644 --- a/src/lib/goal-tracker.ts +++ b/src/lib/goal-tracker.ts @@ -6,6 +6,7 @@ export interface CreateGoalPayload { unit: string; recurrence: Recurrence; deadline: string | null; + category?: string | null; } interface SubmitGoalOptions { diff --git a/src/lib/ssrf-protection.ts b/src/lib/ssrf-protection.ts index 3547600fd..c2f2638ab 100644 --- a/src/lib/ssrf-protection.ts +++ b/src/lib/ssrf-protection.ts @@ -9,7 +9,7 @@ const PRIVATE_RANGES = [ { start: 0xa9fe0000, end: 0xa9feffff }, ]; -function ipToNumber(ip: string): number { +export function ipToNumber(ip: string): number { const parts = ip.split("."); if (parts.length !== 4) return NaN; const numParts = parts.map(Number); @@ -17,7 +17,7 @@ function ipToNumber(ip: string): number { return ((numParts[0] << 24) | (numParts[1] << 16) | (numParts[2] << 8) | numParts[3]) >>> 0; } -function isPrivateIP(ip: string): boolean { +export function isPrivateIP(ip: string): boolean { ip = ip.toLowerCase(); // Extract IPv4 from IPv6-mapped IPv4 address diff --git a/src/types/project-milestone.ts b/src/types/project-milestone.ts new file mode 100644 index 000000000..965b93b6a --- /dev/null +++ b/src/types/project-milestone.ts @@ -0,0 +1,13 @@ +export interface Task { + id: string; + title: string; + completed: boolean; +} + +export interface Milestone { + id: string; + name: string; + description?: string; + dueDate: string; + taskIds: string[]; +} diff --git a/supabase/migrations/20260622000000_add_goal_category.sql b/supabase/migrations/20260622000000_add_goal_category.sql new file mode 100644 index 000000000..0844f5950 --- /dev/null +++ b/supabase/migrations/20260622000000_add_goal_category.sql @@ -0,0 +1 @@ +ALTER TABLE goals ADD COLUMN category TEXT; diff --git a/test/ssrf-protection.test.ts b/test/ssrf-protection.test.ts index 42afba1da..2a400ac8d 100644 --- a/test/ssrf-protection.test.ts +++ b/test/ssrf-protection.test.ts @@ -1,112 +1,97 @@ -import { validateUrlBasic, isSafeUrl } from "../src/lib/ssrf-protection"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import dns from "dns/promises"; +import { ipToNumber, isPrivateIP, validateUrlBasic } from "../src/lib/ssrf-protection"; +import { describe, it, expect } from "vitest"; + +describe("ssrf-protection pure utility functions", () => { + describe("ipToNumber", () => { + it("should convert valid IPv4 addresses to numbers", () => { + expect(ipToNumber("0.0.0.0")).toBe(0); + expect(ipToNumber("255.255.255.255")).toBe(4294967295); + expect(ipToNumber("192.168.1.1")).toBe(3232235777); + }); + + it("should return NaN for invalid formats (non-numeric parts, out-of-range values, wrong octet count)", () => { + // non-numeric parts + expect(ipToNumber("192.168.1.a")).toBeNaN(); + expect(ipToNumber("not.an.ip.address")).toBeNaN(); + // out-of-range values + expect(ipToNumber("192.168.1.256")).toBeNaN(); + expect(ipToNumber("192.-1.1.1")).toBeNaN(); + // wrong octet count + expect(ipToNumber("192.168.1")).toBeNaN(); + expect(ipToNumber("192.168.1.1.1")).toBeNaN(); + // other invalid formats + expect(ipToNumber("")).toBeNaN(); + }); + }); -vi.mock("dns/promises", () => ({ - default: { - lookup: vi.fn(), - }, -})); + describe("isPrivateIP", () => { + it("should return true for all five private IPv4 ranges", () => { + // 10.0.0.0/8 + expect(isPrivateIP("10.0.0.0")).toBe(true); + expect(isPrivateIP("10.255.255.255")).toBe(true); + // 172.16.0.0/12 + expect(isPrivateIP("172.16.0.0")).toBe(true); + expect(isPrivateIP("172.31.255.255")).toBe(true); + // 192.168.0.0/16 + expect(isPrivateIP("192.168.0.0")).toBe(true); + expect(isPrivateIP("192.168.255.255")).toBe(true); + // 127.0.0.0/8 + expect(isPrivateIP("127.0.0.0")).toBe(true); + expect(isPrivateIP("127.255.255.255")).toBe(true); + expect(isPrivateIP("127.0.0.1")).toBe(true); + // 169.254.0.0/16 + expect(isPrivateIP("169.254.0.0")).toBe(true); + expect(isPrivateIP("169.254.255.255")).toBe(true); + }); + + it("should return true for IPv6 loopback and link-local addresses", () => { + expect(isPrivateIP("::1")).toBe(true); + expect(isPrivateIP("::")).toBe(true); + expect(isPrivateIP("fe80::1")).toBe(true); + expect(isPrivateIP("fc00::1")).toBe(true); + expect(isPrivateIP("fd00::1")).toBe(true); + }); + + it("should handle IPv6-mapped IPv4 addresses correctly", () => { + // mapped private + expect(isPrivateIP("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateIP("::ffff:192.168.1.1")).toBe(true); + // mapped public + expect(isPrivateIP("::ffff:8.8.8.8")).toBe(false); + }); + + it("should verify public IPs are not flagged", () => { + expect(isPrivateIP("8.8.8.8")).toBe(false); + expect(isPrivateIP("1.1.1.1")).toBe(false); + // Just outside private ranges + expect(isPrivateIP("172.32.0.0")).toBe(false); + expect(isPrivateIP("192.169.0.0")).toBe(false); + expect(isPrivateIP("9.255.255.255")).toBe(false); + expect(isPrivateIP("11.0.0.0")).toBe(false); + // Public IPv6 + expect(isPrivateIP("2001:4860:4860::8888")).toBe(false); + }); + }); -describe("ssrf-protection", () => { describe("validateUrlBasic", () => { - it("should return true for valid http URL", () => { + it("should return true for http and https URLs", () => { expect(validateUrlBasic("http://example.com")).toBe(true); - }); - - it("should return true for valid https URL", () => { expect(validateUrlBasic("https://example.com")).toBe(true); + expect(validateUrlBasic("http://example.com:8080/path?query=1#hash")).toBe(true); }); - it("should return true for https URL with port", () => { - expect(validateUrlBasic("https://example.com:8080")).toBe(true); - }); - - it("should return true for http URL with path", () => { - expect(validateUrlBasic("http://example.com/path/to/resource")).toBe(true); - }); - - it("should return false for invalid protocol", () => { + it("should return false for other protocols (ftp, data:, javascript:)", () => { expect(validateUrlBasic("ftp://example.com")).toBe(false); + expect(validateUrlBasic("data:text/plain;base64,SGVsbG8=")).toBe(false); + expect(validateUrlBasic("javascript:alert(1)")).toBe(false); expect(validateUrlBasic("file:///etc/passwd")).toBe(false); - expect(validateUrlBasic("ssh://example.com")).toBe(false); }); - it("should return false for malformed URL", () => { + it("should return false for malformed URLs", () => { expect(validateUrlBasic("not-a-url")).toBe(false); - expect(validateUrlBasic("")).toBe(false); expect(validateUrlBasic("://example.com")).toBe(false); - }); - - it("should return false for URL with no protocol", () => { + expect(validateUrlBasic("")).toBe(false); expect(validateUrlBasic("example.com")).toBe(false); - expect(validateUrlBasic("example.com/path")).toBe(false); - }); - - it("should return false for data URL", () => { - expect(validateUrlBasic("data:text/html,")).toBe(false); - }); - - it("should return false for javascript URL", () => { - expect(validateUrlBasic("javascript:alert(1)")).toBe(false); - }); - - it("should handle URLs with query parameters", () => { - expect(validateUrlBasic("https://example.com?foo=bar")).toBe(true); - expect(validateUrlBasic("https://example.com/path?foo=bar&baz=qux")).toBe(true); - }); - - it("should handle URLs with fragments", () => { - expect(validateUrlBasic("https://example.com#section")).toBe(true); - expect(validateUrlBasic("https://example.com/path#section")).toBe(true); - }); - }); - - describe("isSafeUrl", () => { - const mockLookup = dns.lookup as any; - - beforeEach(() => { - mockLookup.mockReset(); - }); - - it("should return false for invalid protocol", async () => { - expect(await isSafeUrl("ftp://example.com")).toBe(false); - }); - - it("should return false for localhost and 0.0.0.0 bypasses", async () => { - expect(await isSafeUrl("http://localhost")).toBe(false); - expect(await isSafeUrl("http://0.0.0.0")).toBe(false); - expect(await isSafeUrl("http://[::1]")).toBe(false); - }); - - it("should return true for public IP literals directly without DNS", async () => { - expect(await isSafeUrl("http://8.8.8.8")).toBe(true); - expect(await isSafeUrl("http://[2001:4860:4860::8888]")).toBe(true); - }); - - it("should return true for public IPs via DNS", async () => { - mockLookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]); - expect(await isSafeUrl("http://example.com")).toBe(true); - }); - - it("should return false for private IPv4", async () => { - mockLookup.mockResolvedValue([{ address: "10.0.0.1", family: 4 }]); - expect(await isSafeUrl("http://internal.com")).toBe(false); - }); - - it("should return false for IPv6-mapped IPv4 private address", async () => { - mockLookup.mockResolvedValue([{ address: "::ffff:192.168.1.1", family: 6 }]); - expect(await isSafeUrl("http://internal.com")).toBe(false); - }); - - it("should return false for IPv6 loopback and link-local", async () => { - mockLookup.mockResolvedValue([{ address: "fe80::1", family: 6 }]); - expect(await isSafeUrl("http://internal.com")).toBe(false); - }); - - it("should return true for public IPv6", async () => { - mockLookup.mockResolvedValue([{ address: "2001:4860:4860::8888", family: 6 }]); - expect(await isSafeUrl("http://example.com")).toBe(true); }); }); });