From d0c4e96b0cc8d8156c4c78e4943180c52976c394 Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Mon, 22 Jun 2026 18:08:17 +0530 Subject: [PATCH 01/12] Fix: Kanban board cards disappear when dragged to Done column #2662 From 1d001939505780f134f9fa6ae689ec24fb027bb6 Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Mon, 22 Jun 2026 18:20:15 +0530 Subject: [PATCH 02/12] feat: add customizable category tags to Goal Tracker (#2647) --- src/app/api/goals/route.ts | 10 ++- src/components/GoalTracker.tsx | 83 ++++++++++++++++++- src/lib/goal-tracker.ts | 1 + .../20260622000000_add_goal_category.sql | 1 + 4 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260622000000_add_goal_category.sql 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 41c6b0fa4..3a855d2af 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 ? Math.max(1, Math.min(Math.round((goal.current / goal.target) * 100), 100)) @@ -577,6 +626,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/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/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; From c7706afd97ee8430bf23263ff65cf0d524811dde Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Mon, 22 Jun 2026 22:49:01 +0530 Subject: [PATCH 03/12] test: add unit tests for ssrf-protection utility functions (#2615) --- src/lib/ssrf-protection.ts | 4 +- test/ssrf-protection.test.ts | 175 ++++++++++++++++------------------- 2 files changed, 82 insertions(+), 97 deletions(-) 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/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); }); }); }); From 073920135ae80e253b35358c249342a7c4856678 Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Fri, 26 Jun 2026 19:40:52 +0530 Subject: [PATCH 04/12] fix(ci): upgrade pnpm and node versions for e2e and visual regression workflows --- .github/workflows/e2e.yml | 4 ++-- .github/workflows/visual-regression.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6d7a07804..6bbe6bfc3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,10 +30,10 @@ jobs: - uses: pnpm/action-setup@v3 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install app dependencies diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index c261bab33..e5eece0a1 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,12 +44,12 @@ jobs: - uses: pnpm/action-setup@v3 with: - version: 9 + version: 11 - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies From b2b4e516c5acfd1f81b6df905d1aadc3b1002933 Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Sun, 5 Jul 2026 07:49:20 +0530 Subject: [PATCH 05/12] feat(milestones): add milestone tracking with progress visualization --- src/components/ProjectMilestones.tsx | 240 ++++++++++++++++++ .../dashboard/CustomizableDashboard.tsx | 13 + src/lib/dashboard-layout.ts | 5 +- src/types/project-milestone.ts | 13 + 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/components/ProjectMilestones.tsx create mode 100644 src/types/project-milestone.ts 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 f544dfa37..179da3d5f 100644 --- a/src/components/dashboard/CustomizableDashboard.tsx +++ b/src/components/dashboard/CustomizableDashboard.tsx @@ -213,6 +213,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", @@ -248,6 +253,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 = ( @@ -464,6 +470,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 366fb33ea..e4496ee70 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -39,7 +39,8 @@ export type DashboardWidgetId = | "ci-analytics" | "language-breakdown" | "friend-comparison" - | "achievement-progress"; + | "achievement-progress" + | "project-milestones"; export interface DashboardLayoutPreference { version: 1; @@ -96,6 +97,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 = { @@ -137,6 +139,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { "language-breakdown", "friend-comparison", "achievement-progress", + "project-milestones", ], }, hidden: [], 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[]; +} From 0d8921949ebee5db065721c1e6d471ad5322caea Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Sun, 5 Jul 2026 08:30:19 +0530 Subject: [PATCH 06/12] fix(ci): downgrade node to v20 and fix pnpm approvedBuilds for v11 --- .github/workflows/automated-tests.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/e2e.yml | 2 +- .github/workflows/visual-regression.yml | 2 +- package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/automated-tests.yml b/.github/workflows/automated-tests.yml index a5551d4e4..9d2cb49a7 100644 --- a/.github/workflows/automated-tests.yml +++ b/.github/workflows/automated-tests.yml @@ -22,7 +22,7 @@ jobs: - name: Initialize Node.js Environment uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: 'pnpm' - name: Install Project Dependencies (Clean Architecture Ingest) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b95fbca6b..5179dafb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: version: 11 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install dependencies @@ -44,7 +44,7 @@ jobs: version: 11 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install dependencies @@ -67,7 +67,7 @@ jobs: version: 11 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install dependencies @@ -113,7 +113,7 @@ jobs: version: 11 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install dependencies @@ -133,7 +133,7 @@ jobs: version: 11 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6bbe6bfc3..e1ada1dc4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: version: 11 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install app dependencies diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index e5eece0a1..0d240185c 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -49,7 +49,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 20 cache: pnpm - name: Install dependencies diff --git a/package.json b/package.json index 8a86eae67..b62976818 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@swc/core-linux-x64-gnu": "^1.15.41" }, "pnpm": { - "onlyBuiltDependencies": [ + "approvedBuilds": [ "@parcel/watcher", "@scarf/scarf", "@sentry/cli", From 25d26ca66bb7a50f5c39a4daf9d89a43b4ed9dcb Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Sun, 5 Jul 2026 08:35:01 +0530 Subject: [PATCH 07/12] fix(ci): downgrade pnpm to v9 to bypass strict approve-builds --- .github/workflows/automated-tests.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/e2e.yml | 2 +- .github/workflows/visual-regression.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/automated-tests.yml b/.github/workflows/automated-tests.yml index eae7d2fc9..1230479e2 100644 --- a/.github/workflows/automated-tests.yml +++ b/.github/workflows/automated-tests.yml @@ -17,7 +17,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - name: Initialize Node.js Environment uses: actions/setup-node@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5ddcf917..2aa7c8f80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - uses: actions/setup-node@v6 with: node-version: 20 @@ -41,7 +41,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - uses: actions/setup-node@v6 with: node-version: 20 @@ -64,7 +64,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - uses: actions/setup-node@v6 with: node-version: 20 @@ -110,7 +110,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - uses: actions/setup-node@v6 with: node-version: 20 @@ -130,7 +130,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - uses: actions/setup-node@v6 with: node-version: 20 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 99da69366..d769a30f7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,7 +30,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - uses: actions/setup-node@v6 with: node-version: 22 diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 401300788..e191d3dbe 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,7 +44,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 11 + version: 9 - name: Setup Node uses: actions/setup-node@v6 From 13e2c72b063e7a6b246af016b42b63e3fbbac53e Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Sun, 5 Jul 2026 08:40:45 +0530 Subject: [PATCH 08/12] fix(ci): restore original upstream ci configuration to fix builds --- .github/workflows/automated-tests.yml | 4 ++-- .github/workflows/ci.yml | 20 ++++++++++---------- .github/workflows/e2e.yml | 2 +- .github/workflows/visual-regression.yml | 2 +- package.json | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/automated-tests.yml b/.github/workflows/automated-tests.yml index 1230479e2..22438c06d 100644 --- a/.github/workflows/automated-tests.yml +++ b/.github/workflows/automated-tests.yml @@ -17,12 +17,12 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - name: Initialize Node.js Environment uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Install Project Dependencies (Clean Architecture Ingest) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aa7c8f80..428bb6922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies @@ -41,10 +41,10 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies @@ -64,10 +64,10 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies @@ -110,10 +110,10 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies @@ -130,10 +130,10 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d769a30f7..99da69366 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,7 +30,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: node-version: 22 diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index e191d3dbe..401300788 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,7 +44,7 @@ jobs: - uses: pnpm/action-setup@v6 with: - version: 9 + version: 11 - name: Setup Node uses: actions/setup-node@v6 diff --git a/package.json b/package.json index 3200fa86f..7a4549cba 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@swc/core-linux-x64-gnu": "^1.15.43" }, "pnpm": { - "approvedBuilds": [ + "onlyBuiltDependencies": [ "@parcel/watcher", "@scarf/scarf", "@sentry/cli", From 737164297e335220b252b96b5b3c7deb1b0ed1de Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Fri, 24 Jul 2026 18:57:15 +0530 Subject: [PATCH 09/12] feat(milestones): add milestone tracking with progress visualization --- src/app/api/milestones/[id]/route.ts | 133 ++++++------ src/app/api/milestones/route.ts | 191 ++++++++---------- src/app/api/tasks/[id]/route.ts | 96 +++++++++ src/app/api/tasks/route.ts | 76 +++++++ src/components/ProjectMilestones.tsx | 135 ++++++++----- .../20260724182104_add_milestones_tasks.sql | 65 ++++++ supabase/schema.sql | 66 ++++++ 7 files changed, 542 insertions(+), 220 deletions(-) create mode 100644 src/app/api/tasks/[id]/route.ts create mode 100644 src/app/api/tasks/route.ts create mode 100644 supabase/migrations/20260724182104_add_milestones_tasks.sql diff --git a/src/app/api/milestones/[id]/route.ts b/src/app/api/milestones/[id]/route.ts index a905293b9..f24ebb930 100644 --- a/src/app/api/milestones/[id]/route.ts +++ b/src/app/api/milestones/[id]/route.ts @@ -2,73 +2,85 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; -import { NextResponse } from "next/server"; +import { stripHtml } from "@/lib/sanitize"; export const dynamic = "force-dynamic"; -export async function PATCH( +export async function PUT( req: Request, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); - let body: unknown; try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + const body = await req.json(); + const name = stripHtml(body.name || "").trim(); + const description = stripHtml(body.description || "").trim(); + const dueDate = body.dueDate || null; + const taskIds = body.taskIds || []; + + if (!name) { + return new Response("Name is required", { status: 400 }); + } + + const { data: existing } = await supabaseAdmin + .from("milestones") + .select("id") + .eq("id", id) + .eq("user_id", appUser.id) + .single(); + + if (!existing) { + return new Response("Milestone not found", { status: 404 }); + } + + const { data: milestone, error } = await supabaseAdmin + .from("milestones") + .update({ + name, + description, + due_date: dueDate, + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .eq("user_id", appUser.id) + .select() + .single(); + + if (error) throw error; + + // First unlink any tasks currently linked to this milestone + const { error: unlinkError } = await supabaseAdmin + .from("tasks") + .update({ milestone_id: null }) + .eq("milestone_id", id) + .eq("user_id", appUser.id); + if (unlinkError) throw unlinkError; + + // Link the new tasks + if (taskIds.length > 0) { + const { error: linkError } = await supabaseAdmin + .from("tasks") + .update({ milestone_id: id }) + .in("id", taskIds) + .eq("user_id", appUser.id); + if (linkError) throw linkError; + } + + return new Response(JSON.stringify(milestone), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); } - - if (typeof body !== "object" || body === null) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const { currentValue } = body as Record; - - if ( - typeof currentValue !== "number" || - !Number.isInteger(currentValue) || - currentValue < 0 - ) { - return NextResponse.json( - { error: "currentValue must be a non-negative integer" }, - { status: 400 } - ); - } - - const { data: existing } = await supabaseAdmin - .from("milestones") - .select("target_value") - .eq("id", id) - .eq("user_id", user.id) - .single(); - - if (!existing) { - return NextResponse.json({ error: "Milestone not found" }, { status: 404 }); - } - - const safeCurrentValue = Math.min(currentValue, existing.target_value); - - const { data, error } = await supabaseAdmin - .from("milestones") - .update({ current_value: safeCurrentValue }) - .eq("id", id) - .eq("user_id", user.id) - .select() - .single(); - - if (error) { - return NextResponse.json({ error: "Failed to update milestone" }, { status: 500 }); - } - - return NextResponse.json({ milestone: data }); } export async function DELETE( @@ -78,21 +90,24 @@ export async function DELETE( const { id } = await params; const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); const { error } = await supabaseAdmin .from("milestones") .delete() .eq("id", id) - .eq("user_id", user.id); + .eq("user_id", appUser.id); if (error) { - return NextResponse.json({ error: "Failed to delete milestone" }, { status: 500 }); + return new Response(error.message, { status: 500 }); } - return NextResponse.json({ success: true }); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } diff --git a/src/app/api/milestones/route.ts b/src/app/api/milestones/route.ts index 4019a3d7f..0733f42f1 100644 --- a/src/app/api/milestones/route.ts +++ b/src/app/api/milestones/route.ts @@ -3,136 +3,111 @@ import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { stripHtml } from "@/lib/sanitize"; -import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; -const MAX_MILESTONES_PER_USER = 20; -const MAX_TITLE_LEN = 100; -const MAX_DESCRIPTION_LEN = 300; -const MAX_UNIT_LEN = 30; -const VALID_CATEGORIES = ["commits", "streak", "projects", "custom"] as const; -type Category = (typeof VALID_CATEGORIES)[number]; - export async function GET() { const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } - const { data, error } = await supabaseAdmin + const { data: milestones, error } = await supabaseAdmin .from("milestones") - .select("*") - .eq("user_id", user.id) - .order("created_at", { ascending: false }) - .limit(MAX_MILESTONES_PER_USER); + .select("id, name, description, due_date") + .eq("user_id", appUser.id) + .order("created_at", { ascending: false }); if (error) { - return NextResponse.json({ error: "Failed to fetch milestones" }, { status: 500 }); + return new Response(error.message, { status: 500 }); } - return NextResponse.json({ milestones: data ?? [] }); + // Fetch tasks to calculate taskIds and completion status per milestone + const { data: tasks, error: taskError } = await supabaseAdmin + .from("tasks") + .select("id, milestone_id, completed") + .eq("user_id", appUser.id) + .not("milestone_id", "is", null); + + if (taskError) { + return new Response(taskError.message, { status: 500 }); + } + + const result = milestones.map((m) => { + const mTasks = tasks.filter((t) => t.milestone_id === m.id); + return { + id: m.id, + name: m.name, + description: m.description, + dueDate: m.due_date, + taskIds: mTasks.map((t) => t.id), + totalTasks: mTasks.length, + completedTasks: mTasks.filter((t) => t.completed).length, + }; + }); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } export async function POST(req: Request) { const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - if (typeof body !== "object" || body === null) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const { title, description, targetValue, currentValue, unit, targetDate, category } = - body as Record; - - if (typeof title !== "string" || title.trim().length === 0) { - return NextResponse.json({ error: "title must be a non-empty string" }, { status: 400 }); - } - const safeTitle = stripHtml(title).slice(0, MAX_TITLE_LEN); - if (safeTitle.length === 0) { - return NextResponse.json({ error: "title must not be empty" }, { status: 400 }); + return new Response("Unauthorized", { status: 401 }); } - const safeDescription = - typeof description === "string" - ? stripHtml(description).slice(0, MAX_DESCRIPTION_LEN) - : ""; - - if ( - typeof targetValue !== "number" || - !Number.isInteger(targetValue) || - targetValue < 1 || - targetValue > 1_000_000 - ) { - return NextResponse.json( - { error: "targetValue must be an integer between 1 and 1,000,000" }, - { status: 400 } - ); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); } - const safeCurrentValue = - typeof currentValue === "number" && Number.isInteger(currentValue) && currentValue >= 0 - ? Math.min(currentValue, targetValue) - : 0; - - const safeUnit = - typeof unit === "string" && unit.trim().length > 0 - ? unit.trim().slice(0, MAX_UNIT_LEN) - : "units"; - - if (typeof targetDate !== "string" || isNaN(new Date(targetDate).getTime())) { - return NextResponse.json({ error: "targetDate must be a valid date string" }, { status: 400 }); - } - - const safeCategory: Category = VALID_CATEGORIES.includes(category as Category) - ? (category as Category) - : "custom"; - - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); - - const { count } = await supabaseAdmin - .from("milestones") - .select("*", { count: "exact", head: true }) - .eq("user_id", user.id); - - if ((count ?? 0) >= MAX_MILESTONES_PER_USER) { - return NextResponse.json( - { error: `You can have at most ${MAX_MILESTONES_PER_USER} milestones.` }, - { status: 400 } - ); - } - - const { data, error } = await supabaseAdmin - .from("milestones") - .insert({ - user_id: user.id, - title: safeTitle, - description: safeDescription, - target_value: targetValue, - current_value: safeCurrentValue, - unit: safeUnit, - target_date: targetDate, - category: safeCategory, - }) - .select() - .single(); - - if (error) { - return NextResponse.json({ error: "Failed to create milestone" }, { status: 500 }); + try { + const body = await req.json(); + const name = stripHtml(body.name || "").trim(); + const description = stripHtml(body.description || "").trim(); + const dueDate = body.dueDate || null; + const taskIds = body.taskIds || []; + + if (!name) { + return new Response("Name is required", { status: 400 }); + } + + const { data: milestone, error } = await supabaseAdmin + .from("milestones") + .insert({ + user_id: appUser.id, + name, + description, + due_date: dueDate, + }) + .select() + .single(); + + if (error) throw error; + + // Link tasks if any + if (taskIds.length > 0) { + const { error: updateError } = await supabaseAdmin + .from("tasks") + .update({ milestone_id: milestone.id }) + .in("id", taskIds) + .eq("user_id", appUser.id); + + if (updateError) throw updateError; + } + + return new Response(JSON.stringify(milestone), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); } - - return NextResponse.json({ milestone: data }, { status: 201 }); } diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 000000000..a5126897c --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -0,0 +1,96 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { stripHtml } from "@/lib/sanitize"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); + + try { + const body = await req.json(); + + const updates: any = {}; + if (body.title !== undefined) { + updates.title = stripHtml(body.title).trim(); + if (!updates.title) return new Response("Title cannot be empty", { status: 400 }); + } + if (body.completed !== undefined) { + updates.completed = Boolean(body.completed); + } + if (body.milestoneId !== undefined) { + updates.milestone_id = body.milestoneId; + } + + updates.updated_at = new Date().toISOString(); + + const { data: existing } = await supabaseAdmin + .from("tasks") + .select("id") + .eq("id", id) + .eq("user_id", appUser.id) + .single(); + + if (!existing) { + return new Response("Task not found", { status: 404 }); + } + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .update(updates) + .eq("id", id) + .eq("user_id", appUser.id) + .select() + .single(); + + if (error) throw error; + + return new Response(JSON.stringify(task), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); + + const { error } = await supabaseAdmin + .from("tasks") + .delete() + .eq("id", id) + .eq("user_id", appUser.id); + + if (error) { + return new Response(error.message, { status: 500 }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 000000000..11b501a47 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,76 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { stripHtml } from "@/lib/sanitize"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } + + const { data: tasks, error } = await supabaseAdmin + .from("tasks") + .select("*") + .eq("user_id", appUser.id) + .order("created_at", { ascending: false }); + + if (error) { + return new Response(error.message, { status: 500 }); + } + + return new Response(JSON.stringify(tasks), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } + + try { + const body = await req.json(); + const title = stripHtml(body.title || "").trim(); + const milestone_id = body.milestoneId || null; + + if (!title) { + return new Response("Title is required", { status: 400 }); + } + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .insert({ + user_id: appUser.id, + title, + milestone_id, + completed: false, + }) + .select() + .single(); + + if (error) throw error; + + return new Response(JSON.stringify(task), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +} diff --git a/src/components/ProjectMilestones.tsx b/src/components/ProjectMilestones.tsx index 6d729d686..943370a0e 100644 --- a/src/components/ProjectMilestones.tsx +++ b/src/components/ProjectMilestones.tsx @@ -8,6 +8,7 @@ export default function ProjectMilestones() { const [milestones, setMilestones] = useState([]); const [tasks, setTasks] = useState([]); const [isClient, setIsClient] = useState(false); + const [loading, setLoading] = useState(true); const [showMilestoneForm, setShowMilestoneForm] = useState(false); const [milestoneForm, setMilestoneForm] = useState({ name: '', description: '', dueDate: '' }); @@ -16,74 +17,98 @@ export default function ProjectMilestones() { 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)); + Promise.all([ + fetch('/api/milestones').then(r => r.json()), + fetch('/api/tasks').then(r => r.json()) + ]).then(([mils, tsks]) => { + setMilestones(Array.isArray(mils) ? mils : []); + setTasks(Array.isArray(tsks) ? tsks : []); + setLoading(false); + }).catch(e => { + console.error(e); + setLoading(false); + }); }, []); - 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 = () => { + const handleCreateMilestone = async () => { if (!milestoneForm.name || !milestoneForm.dueDate) return; - const newMilestone: Milestone = { - id: crypto.randomUUID(), - name: milestoneForm.name, - description: milestoneForm.description, - dueDate: milestoneForm.dueDate, - taskIds: [], - }; + const res = await fetch('/api/milestones', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: milestoneForm.name, + description: milestoneForm.description, + dueDate: milestoneForm.dueDate, + taskIds: [] + }) + }); - setMilestones(prev => [newMilestone, ...prev]); - setMilestoneForm({ name: '', description: '', dueDate: '' }); - setShowMilestoneForm(false); + if (res.ok) { + const newMilestone = await res.json(); + setMilestones(prev => [newMilestone, ...prev]); + setMilestoneForm({ name: '', description: '', dueDate: '' }); + setShowMilestoneForm(false); + } }; - const handleDeleteMilestone = (id: string) => { - setMilestones(prev => prev.filter(m => m.id !== id)); + const handleDeleteMilestone = async (id: string) => { + const res = await fetch(`/api/milestones/${id}`, { method: 'DELETE' }); + if (res.ok) { + setMilestones(prev => prev.filter(m => m.id !== id)); + } }; - const handleAddTask = (milestoneId: string) => { + const handleAddTask = async (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 res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, milestoneId }) + }); + + if (res.ok) { + const newTask = await res.json(); + 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 handleToggleTask = async (taskId: string, currentCompleted: boolean) => { + setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: !currentCompleted } : t)); + + const res = await fetch(`/api/tasks/${taskId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ completed: !currentCompleted }) + }); + + if (!res.ok) { + setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: currentCompleted } : 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; - })); + const handleDeleteTask = async (milestoneId: string, taskId: string) => { + const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); + if (res.ok) { + 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 ( @@ -154,7 +179,11 @@ export default function ProjectMilestones() {
)} - {milestones.length === 0 ? ( + {loading ? ( +
+ Loading milestones... +
+ ) : milestones.length === 0 ? (

No project milestones yet. Create one to start tracking!

@@ -162,7 +191,7 @@ export default function ProjectMilestones() { ) : (
{milestones.map(m => { - const mTasks = m.taskIds.map(id => tasks.find(t => t.id === id)).filter((t): t is Task => !!t); + 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; @@ -198,7 +227,7 @@ export default function ProjectMilestones() {
    {mTasks.map(task => (
  • -
    handleToggleTask(task.id)}> +
    handleToggleTask(task.id, task.completed)}> {task.completed ? : } {task.title}
    diff --git a/supabase/migrations/20260724182104_add_milestones_tasks.sql b/supabase/migrations/20260724182104_add_milestones_tasks.sql new file mode 100644 index 000000000..c39b36fd8 --- /dev/null +++ b/supabase/migrations/20260724182104_add_milestones_tasks.sql @@ -0,0 +1,65 @@ +create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +drop table if exists milestones cascade; + +create table milestones ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + name text not null, + description text, + due_date timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +alter table tasks add constraint fk_tasks_milestone + foreign key (milestone_id) references milestones(id) on delete set null; + +create index if not exists tasks_user_id_idx on tasks(user_id); +create index if not exists milestones_user_id_idx on milestones(user_id); +create index if not exists tasks_milestone_id_idx on tasks(milestone_id); + +alter table tasks enable row level security; +alter table milestones enable row level security; + +create policy "tasks_select_own" + on tasks for select + using (user_id = auth.uid()::text); + +create policy "tasks_insert_own" + on tasks for insert + with check (user_id = auth.uid()::text); + +create policy "tasks_update_own" + on tasks for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "tasks_delete_own" + on tasks for delete + using (user_id = auth.uid()::text); + +create policy "milestones_select_own" + on milestones for select + using (user_id = auth.uid()::text); + +create policy "milestones_insert_own" + on milestones for insert + with check (user_id = auth.uid()::text); + +create policy "milestones_update_own" + on milestones for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "milestones_delete_own" + on milestones for delete + using (user_id = auth.uid()::text); diff --git a/supabase/schema.sql b/supabase/schema.sql index 6abd62602..4eee2b0d0 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -334,3 +334,69 @@ CREATE POLICY "Users can delete own sponsor metrics" ON user_sponsor_metrics FOR DELETE USING (auth.uid()::text = user_id); +-- ------------------------------------------------------- +-- Tasks and Milestones +-- ------------------------------------------------------- +create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table if not exists milestones ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + name text not null, + description text, + due_date timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +alter table tasks add constraint fk_tasks_milestone + foreign key (milestone_id) references milestones(id) on delete set null; + +create index if not exists tasks_user_id_idx on tasks(user_id); +create index if not exists milestones_user_id_idx on milestones(user_id); +create index if not exists tasks_milestone_id_idx on tasks(milestone_id); + +alter table tasks enable row level security; +alter table milestones enable row level security; + +create policy "tasks_select_own" + on tasks for select + using (user_id = auth.uid()::text); + +create policy "tasks_insert_own" + on tasks for insert + with check (user_id = auth.uid()::text); + +create policy "tasks_update_own" + on tasks for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "tasks_delete_own" + on tasks for delete + using (user_id = auth.uid()::text); + +create policy "milestones_select_own" + on milestones for select + using (user_id = auth.uid()::text); + +create policy "milestones_insert_own" + on milestones for insert + with check (user_id = auth.uid()::text); + +create policy "milestones_update_own" + on milestones for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "milestones_delete_own" + on milestones for delete + using (user_id = auth.uid()::text); From 6e56598670c9f6a9f53ce30fe420c73e03f384fc Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Fri, 24 Jul 2026 19:21:27 +0530 Subject: [PATCH 10/12] feat(export): implement CSV and Excel export for task data --- package.json | 3 +- pnpm-lock.yaml | 72 ++++++++++++ scratch_fix_api.py | 24 ++++ scratch_fix_schema.py | 21 ++++ scratch_update_schema.py | 37 +++++++ src/app/api/tasks/[id]/route.ts | 12 ++ src/app/api/tasks/route.ts | 8 ++ src/app/dashboard/page.tsx | 4 +- src/components/TaskExportButton.tsx | 103 ++++++++++++++++++ src/types/project-milestone.ts | 4 + ...4191502_update_tasks_schema_for_export.sql | 11 ++ supabase/schema.sql | 4 + 12 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 scratch_fix_api.py create mode 100644 scratch_fix_schema.py create mode 100644 scratch_update_schema.py create mode 100644 src/components/TaskExportButton.tsx create mode 100644 supabase/migrations/20260724191502_update_tasks_schema_for_export.sql diff --git a/package.json b/package.json index 7a4549cba..252541b82 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "sharp": "^0.35.1", "sonner": "^2.0.7", "swagger-ui-react": "5.32.6", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@emnapi/core": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69cd44ce1..397ae62e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@emnapi/core': specifier: ^1.11.0 @@ -3094,6 +3097,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3430,6 +3437,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3496,6 +3507,10 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + collect-v8-coverage@1.0.3: resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} @@ -3553,6 +3568,11 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4136,6 +4156,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -5995,6 +6019,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -6709,10 +6737,18 @@ packages: engines: {node: '>=8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -6847,6 +6883,11 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xml-but-prettier@1.0.1: resolution: {integrity: sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==} @@ -10064,6 +10105,8 @@ snapshots: acorn@8.17.0: {} + adler-32@1.3.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -10455,6 +10498,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@6.2.2: {} chalk@4.1.2: @@ -10513,6 +10561,8 @@ snapshots: co@4.6.0: {} + codepage@1.15.0: {} + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: @@ -10556,6 +10606,8 @@ snapshots: core-js@3.49.0: optional: true + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -11289,6 +11341,8 @@ snapshots: format@0.2.2: {} + frac@1.1.2: {} + fraction.js@5.3.4: {} fs-extra@9.1.0: @@ -13610,6 +13664,10 @@ snapshots: sprintf-js@1.0.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stable-hash@0.0.5: {} stack-utils@2.0.6: @@ -14463,8 +14521,12 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wordwrap@1.0.0: {} workbox-background-sync@6.6.0: @@ -14779,6 +14841,16 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xml-but-prettier@1.0.1: dependencies: repeat-string: 1.6.1 diff --git a/scratch_fix_api.py b/scratch_fix_api.py new file mode 100644 index 000000000..f63daf36f --- /dev/null +++ b/scratch_fix_api.py @@ -0,0 +1,24 @@ +import glob + +files = glob.glob("src/app/api/milestones/**/route.ts", recursive=True) + glob.glob("src/app/api/tasks/**/route.ts", recursive=True) + +for file in files: + with open(file, "r") as f: + content = f.read() + + # Replace the session checks + content = content.replace( + "if (!session?.user?.id && !session?.githubId) {\n return new Response(\"Unauthorized\", { status: 401 });\n }", + "if (!session?.githubId) {\n return new Response(\"Unauthorized\", { status: 401 });\n }" + ) + + # Replace the user id retrieval + content = content.replace( + "const userId = session.user?.id || session.githubId;\n const appUser = await resolveAppUser(userId, session.githubLogin);", + "const appUser = await resolveAppUser(session.githubId, session.githubLogin);" + ) + + with open(file, "w") as f: + f.write(content) + + print("Fixed", file) diff --git a/scratch_fix_schema.py b/scratch_fix_schema.py new file mode 100644 index 000000000..9a88215b1 --- /dev/null +++ b/scratch_fix_schema.py @@ -0,0 +1,21 @@ +import os + +filepath = "supabase/schema.sql" +with open(filepath, "r") as f: + lines = f.readlines() + +new_lines = [] +for line in lines: + if line.startswith("<<<<<<< HEAD"): + continue + elif line.startswith("======="): + continue + elif line.startswith(">>>>>>> upstream/main"): + continue + else: + new_lines.append(line) + +with open(filepath, "w") as f: + f.writelines(new_lines) + +print("Conflict markers removed from", filepath) diff --git a/scratch_update_schema.py b/scratch_update_schema.py new file mode 100644 index 000000000..94152fc93 --- /dev/null +++ b/scratch_update_schema.py @@ -0,0 +1,37 @@ +import sys + +filepath = "supabase/schema.sql" +with open(filepath, "r") as f: + content = f.read() + +old_tasks = """create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +);""" + +new_tasks = """create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + status text not null default 'todo', + priority text not null default 'medium', + due_date timestamptz, + tags text[] not null default '{}'::text[], + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +);""" + +if old_tasks in content: + content = content.replace(old_tasks, new_tasks) + with open(filepath, "w") as f: + f.write(content) + print("Updated schema.sql successfully") +else: + print("Could not find the target text in schema.sql") diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index a5126897c..ea5ace8b0 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -30,6 +30,18 @@ export async function PUT( if (body.completed !== undefined) { updates.completed = Boolean(body.completed); } + if (body.status !== undefined) { + updates.status = stripHtml(body.status).trim(); + } + if (body.priority !== undefined) { + updates.priority = stripHtml(body.priority).trim(); + } + if (body.dueDate !== undefined) { + updates.due_date = body.dueDate; + } + if (body.tags !== undefined && Array.isArray(body.tags)) { + updates.tags = body.tags.map((t: string) => stripHtml(t).trim()); + } if (body.milestoneId !== undefined) { updates.milestone_id = body.milestoneId; } diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 11b501a47..8949ac66c 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -48,6 +48,10 @@ export async function POST(req: Request) { const body = await req.json(); const title = stripHtml(body.title || "").trim(); const milestone_id = body.milestoneId || null; + const status = stripHtml(body.status || "todo").trim(); + const priority = stripHtml(body.priority || "medium").trim(); + const due_date = body.dueDate || null; + const tags = Array.isArray(body.tags) ? body.tags.map((t: string) => stripHtml(t).trim()) : []; if (!title) { return new Response("Title is required", { status: 400 }); @@ -60,6 +64,10 @@ export async function POST(req: Request) { title, milestone_id, completed: false, + status, + priority, + due_date, + tags }) .select() .single(); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 24986fa52..03af0eb46 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import MilestonePlanner from "@/components/MilestonePlanner"; import TodayFocusHero from "@/components/TodayFocusHero"; import DashboardHeader from "@/components/DashboardHeader"; import ExportButton from "@/components/ExportButton"; +import TaskExportButton from "@/components/TaskExportButton"; import Link from "next/link"; import { ChevronRight } from "lucide-react"; import { authOptions } from "@/lib/auth"; @@ -77,8 +78,9 @@ export default async function DashboardPage() { > Settings -
    +
    +
    diff --git a/src/components/TaskExportButton.tsx b/src/components/TaskExportButton.tsx new file mode 100644 index 000000000..d0f97188f --- /dev/null +++ b/src/components/TaskExportButton.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { Download } from "lucide-react"; +import { Task } from "@/types/project-milestone"; + +export default function TaskExportButton() { + const [loading, setLoading] = useState(false); + + const fetchTasks = async (): Promise => { + const res = await fetch("/api/tasks"); + if (!res.ok) throw new Error("Failed to fetch tasks"); + return res.json(); + }; + + const handleExportCSV = async () => { + try { + setLoading(true); + const tasks = await fetchTasks(); + + const header = ["id", "title", "status", "priority", "due_date", "created_at", "tags"].join(","); + const rows = tasks.map(t => { + // Handle special characters in title by wrapping in quotes and escaping internal quotes + const title = `"${(t.title || "").replace(/"/g, '""')}"`; + const status = t.status || (t.completed ? "done" : "todo"); + const priority = t.priority || "medium"; + const dueDate = t.dueDate || ""; + // Assume t as any since created_at might be returned from API + const createdAt = (t as any).created_at || ""; + const tags = `"${(t.tags || []).join(";")}"`; + + return [t.id, title, status, priority, dueDate, createdAt, tags].join(","); + }); + + const csvContent = [header, ...rows].join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `devtrack_tasks_${new Date().toISOString().split("T")[0]}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { + console.error(err); + alert("Failed to export tasks to CSV."); + } finally { + setLoading(false); + } + }; + + const handleExportExcel = async () => { + try { + setLoading(true); + const tasks = await fetchTasks(); + + // Dynamic import to keep bundle size small if not used + const XLSX = await import("xlsx"); + + const data = tasks.map(t => ({ + ID: t.id, + Title: t.title, + Status: t.status || (t.completed ? "done" : "todo"), + Priority: t.priority || "medium", + "Due Date": t.dueDate || "", + "Created At": (t as any).created_at || "", + Tags: (t.tags || []).join(", ") + })); + + const worksheet = XLSX.utils.json_to_sheet(data); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Tasks"); + + XLSX.writeFile(workbook, `devtrack_tasks_${new Date().toISOString().split("T")[0]}.xlsx`); + } catch (err) { + console.error(err); + alert("Failed to export tasks to Excel."); + } finally { + setLoading(false); + } + }; + + return ( +
    + + +
    + ); +} diff --git a/src/types/project-milestone.ts b/src/types/project-milestone.ts index 965b93b6a..ab4d769f5 100644 --- a/src/types/project-milestone.ts +++ b/src/types/project-milestone.ts @@ -2,6 +2,10 @@ export interface Task { id: string; title: string; completed: boolean; + status: string; + priority: string; + dueDate?: string; + tags: string[]; } export interface Milestone { diff --git a/supabase/migrations/20260724191502_update_tasks_schema_for_export.sql b/supabase/migrations/20260724191502_update_tasks_schema_for_export.sql new file mode 100644 index 000000000..02f70ea7a --- /dev/null +++ b/supabase/migrations/20260724191502_update_tasks_schema_for_export.sql @@ -0,0 +1,11 @@ +alter table tasks +add column if not exists status text not null default 'todo', +add column if not exists priority text not null default 'medium', +add column if not exists due_date timestamptz, +add column if not exists tags text[] not null default '{}'::text[]; + +-- Migrate existing completed tasks to status +update tasks set status = 'done' where completed = true; + +-- We can leave the `completed` column for now to avoid breaking existing code while we transition, +-- or drop it. We'll leave it for backward compatibility and just keep it in sync or phase it out. diff --git a/supabase/schema.sql b/supabase/schema.sql index 9b4923998..d1ec1323d 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -343,6 +343,10 @@ create table if not exists tasks ( user_id text not null references users(id) on delete cascade, title text not null, completed boolean not null default false, + status text not null default 'todo', + priority text not null default 'medium', + due_date timestamptz, + tags text[] not null default '{}'::text[], milestone_id text, created_at timestamptz default now(), updated_at timestamptz default now() From d3888218b33d91f48a01e3ff8a53a0f2354fc1af Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Fri, 24 Jul 2026 19:43:35 +0530 Subject: [PATCH 11/12] feat(tasks): add recurring task support with configurable intervals --- scratch_update_api.py | 198 ++++++++++++++++++ scratch_update_pm.py | 170 +++++++++++++++ src/app/api/tasks/[id]/route.ts | 45 +++- src/app/api/tasks/route.ts | 5 +- src/components/ProjectMilestones.tsx | 99 +++++++-- src/types/project-milestone.ts | 8 + .../20260724193933_add_task_recurrence.sql | 3 + supabase/schema.sql | 2 + 8 files changed, 507 insertions(+), 23 deletions(-) create mode 100644 scratch_update_api.py create mode 100644 scratch_update_pm.py create mode 100644 supabase/migrations/20260724193933_add_task_recurrence.sql diff --git a/scratch_update_api.py b/scratch_update_api.py new file mode 100644 index 000000000..1f118d60f --- /dev/null +++ b/scratch_update_api.py @@ -0,0 +1,198 @@ +import sys + +filepath = "src/app/api/tasks/[id]/route.ts" +with open(filepath, "r") as f: + content = f.read() + +# We need to find the PUT function and replace its logic. +# The PUT function starts with `export async function PUT` + +old_put = """export async function PUT( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } + + try { + const body = await req.json(); + const updates: any = {}; + if (body.title !== undefined) { + updates.title = stripHtml(body.title).trim(); + if (!updates.title) return new Response("Title cannot be empty", { status: 400 }); + } + if (body.completed !== undefined) { + updates.completed = Boolean(body.completed); + } + if (body.status !== undefined) { + updates.status = stripHtml(body.status).trim(); + } + if (body.priority !== undefined) { + updates.priority = stripHtml(body.priority).trim(); + } + if (body.dueDate !== undefined) { + updates.due_date = body.dueDate; + } + if (body.tags !== undefined && Array.isArray(body.tags)) { + updates.tags = body.tags.map((t: string) => stripHtml(t).trim()); + } + if (body.milestoneId !== undefined) { + updates.milestone_id = body.milestoneId; + } + + if (Object.keys(updates).length === 0) { + return new Response("No updates provided", { status: 400 }); + } + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .update(updates) + .eq("id", params.id) + .eq("user_id", appUser.id) + .select() + .single(); + + if (error) throw error; + if (!task) return new Response("Task not found", { status: 404 }); + + return new Response(JSON.stringify(task), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +}""" + +new_put = """export async function PUT( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } + + try { + const body = await req.json(); + const updates: any = {}; + if (body.title !== undefined) { + updates.title = stripHtml(body.title).trim(); + if (!updates.title) return new Response("Title cannot be empty", { status: 400 }); + } + if (body.completed !== undefined) { + updates.completed = Boolean(body.completed); + } + if (body.status !== undefined) { + updates.status = stripHtml(body.status).trim(); + } + if (body.priority !== undefined) { + updates.priority = stripHtml(body.priority).trim(); + } + if (body.dueDate !== undefined) { + updates.due_date = body.dueDate; + } + if (body.tags !== undefined && Array.isArray(body.tags)) { + updates.tags = body.tags.map((t: string) => stripHtml(t).trim()); + } + if (body.milestoneId !== undefined) { + updates.milestone_id = body.milestoneId; + } + if (body.recurrence_config !== undefined) { + updates.recurrence_config = body.recurrence_config; + } + + if (Object.keys(updates).length === 0) { + return new Response("No updates provided", { status: 400 }); + } + + // Fetch the task first to check recurrence logic + const { data: existingTask, error: fetchError } = await supabaseAdmin + .from("tasks") + .select("*") + .eq("id", params.id) + .eq("user_id", appUser.id) + .single(); + + if (fetchError || !existingTask) { + return new Response("Task not found", { status: 404 }); + } + + const isBeingCompleted = (updates.completed === true || updates.status === 'done') && + (existingTask.completed === false && existingTask.status !== 'done'); + + // Auto-create recurrence if applicable + if (isBeingCompleted && existingTask.recurrence_config) { + const config = existingTask.recurrence_config; + const count = existingTask.recurrence_count || 0; + + if (!config.endsAfter || count < config.endsAfter) { + let nextDueDate = existingTask.due_date ? new Date(existingTask.due_date) : new Date(); + + if (config.type === 'daily') { + nextDueDate.setDate(nextDueDate.getDate() + 1); + } else if (config.type === 'weekly') { + nextDueDate.setDate(nextDueDate.getDate() + 7); + } else if (config.type === 'monthly') { + nextDueDate.setMonth(nextDueDate.getMonth() + 1); + } else if (config.type === 'custom' && config.intervalDays) { + nextDueDate.setDate(nextDueDate.getDate() + config.intervalDays); + } + + // Insert new task + await supabaseAdmin.from("tasks").insert({ + user_id: appUser.id, + title: existingTask.title, + milestone_id: existingTask.milestone_id, + completed: false, + status: 'todo', + priority: existingTask.priority, + due_date: nextDueDate.toISOString(), + tags: existingTask.tags, + recurrence_config: config, + recurrence_count: count + 1 + }); + + // Remove recurrence config from this completed task so it doesn't trigger again + updates.recurrence_config = null; + } + } + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .update(updates) + .eq("id", params.id) + .eq("user_id", appUser.id) + .select() + .single(); + + if (error) throw error; + + return new Response(JSON.stringify(task), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +}""" + +if old_put in content: + content = content.replace(old_put, new_put) + with open(filepath, "w") as f: + f.write(content) + print("Updated src/app/api/tasks/[id]/route.ts successfully") +else: + print("Could not find the target text in src/app/api/tasks/[id]/route.ts") diff --git a/scratch_update_pm.py b/scratch_update_pm.py new file mode 100644 index 000000000..32d02b67b --- /dev/null +++ b/scratch_update_pm.py @@ -0,0 +1,170 @@ +import sys + +filepath = "src/components/ProjectMilestones.tsx" +with open(filepath, "r") as f: + content = f.read() + +# Replace states +old_states = """ const [milestoneForm, setMilestoneForm] = useState({ name: '', description: '', dueDate: '' }); + + const [taskInputs, setTaskInputs] = useState>({});""" + +new_states = """ const [milestoneForm, setMilestoneForm] = useState({ name: '', description: '', dueDate: '' }); + + const [taskInputs, setTaskInputs] = useState>({}); + const [taskRecurrenceInputs, setTaskRecurrenceInputs] = useState>({}); + const [taskRecurrenceIntervals, setTaskRecurrenceIntervals] = useState>({}); + const [taskRecurrenceEnds, setTaskRecurrenceEnds] = useState>({});""" + +content = content.replace(old_states, new_states) + +# Replace handleAddTask +old_add_task = """ const handleAddTask = async (milestoneId: string) => { + const title = taskInputs[milestoneId]?.trim(); + if (!title) return; + + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, milestoneId }) + }); + + if (res.ok) { + const newTask = await res.json(); + 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]: '' })); + } + };""" + +new_add_task = """ const handleAddTask = async (milestoneId: string) => { + const title = taskInputs[milestoneId]?.trim(); + if (!title) return; + + const recType = taskRecurrenceInputs[milestoneId]; + let recurrence_config = null; + if (recType && recType !== 'none') { + recurrence_config = { + type: recType, + intervalDays: recType === 'custom' ? (taskRecurrenceIntervals[milestoneId] || 1) : undefined, + endsAfter: taskRecurrenceEnds[milestoneId] || undefined + }; + } + + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, milestoneId, recurrence_config }) + }); + + if (res.ok) { + const newTask = await res.json(); + 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]: '' })); + setTaskRecurrenceInputs(prev => ({ ...prev, [milestoneId]: 'none' })); + setTaskRecurrenceIntervals(prev => ({ ...prev, [milestoneId]: 1 })); + setTaskRecurrenceEnds(prev => ({ ...prev, [milestoneId]: 0 })); + } + };""" + +content = content.replace(old_add_task, new_add_task) + +# Replace UI for task addition +old_ui = """
    + 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' }} + /> + +
    """ + +new_ui = """
    +
    + 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' }} + /> + +
    +
    + + + {taskRecurrenceInputs[m.id] === 'custom' && ( +
    + Every + setTaskRecurrenceIntervals(prev => ({ ...prev, [m.id]: parseInt(e.target.value) || 1 }))} + style={{ width: '50px', padding: '2px 4px', borderRadius: '4px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)' }} + /> + days +
    + )} + + {(taskRecurrenceInputs[m.id] && taskRecurrenceInputs[m.id] !== 'none') && ( +
    + Ends after (optional): + setTaskRecurrenceEnds(prev => ({ ...prev, [m.id]: parseInt(e.target.value) || 0 }))} + style={{ width: '50px', padding: '2px 4px', borderRadius: '4px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)' }} + /> + times +
    + )} +
    +
    """ + +content = content.replace(old_ui, new_ui) + +with open(filepath, "w") as f: + f.write(content) + +print("Updated ProjectMilestones.tsx") diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index ea5ace8b0..a0a3d3df1 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -45,12 +45,15 @@ export async function PUT( if (body.milestoneId !== undefined) { updates.milestone_id = body.milestoneId; } + if (body.recurrence_config !== undefined) { + updates.recurrence_config = body.recurrence_config; + } updates.updated_at = new Date().toISOString(); const { data: existing } = await supabaseAdmin .from("tasks") - .select("id") + .select("*") .eq("id", id) .eq("user_id", appUser.id) .single(); @@ -59,6 +62,46 @@ export async function PUT( return new Response("Task not found", { status: 404 }); } + const isBeingCompleted = (updates.completed === true || updates.status === 'done') && + (existing.completed === false && existing.status !== 'done'); + + // Auto-create recurrence if applicable + if (isBeingCompleted && existing.recurrence_config) { + const config = existing.recurrence_config; + const count = existing.recurrence_count || 0; + + if (!config.endsAfter || count < config.endsAfter) { + let nextDueDate = existing.due_date ? new Date(existing.due_date) : new Date(); + + if (config.type === 'daily') { + nextDueDate.setDate(nextDueDate.getDate() + 1); + } else if (config.type === 'weekly') { + nextDueDate.setDate(nextDueDate.getDate() + 7); + } else if (config.type === 'monthly') { + nextDueDate.setMonth(nextDueDate.getMonth() + 1); + } else if (config.type === 'custom' && config.intervalDays) { + nextDueDate.setDate(nextDueDate.getDate() + config.intervalDays); + } + + // Insert new task + await supabaseAdmin.from("tasks").insert({ + user_id: appUser.id, + title: existing.title, + milestone_id: existing.milestone_id, + completed: false, + status: 'todo', + priority: existing.priority, + due_date: nextDueDate.toISOString(), + tags: existing.tags, + recurrence_config: config, + recurrence_count: count + 1 + }); + + // Remove recurrence config from this completed task so it doesn't trigger again + updates.recurrence_config = null; + } + } + const { data: task, error } = await supabaseAdmin .from("tasks") .update(updates) diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 8949ac66c..c369e7cab 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -52,6 +52,7 @@ export async function POST(req: Request) { const priority = stripHtml(body.priority || "medium").trim(); const due_date = body.dueDate || null; const tags = Array.isArray(body.tags) ? body.tags.map((t: string) => stripHtml(t).trim()) : []; + const recurrence_config = body.recurrence_config || null; if (!title) { return new Response("Title is required", { status: 400 }); @@ -67,7 +68,9 @@ export async function POST(req: Request) { status, priority, due_date, - tags + tags, + recurrence_config, + recurrence_count: 0 }) .select() .single(); diff --git a/src/components/ProjectMilestones.tsx b/src/components/ProjectMilestones.tsx index 943370a0e..daa18fa3a 100644 --- a/src/components/ProjectMilestones.tsx +++ b/src/components/ProjectMilestones.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Target, Plus, Trash2, CheckCircle2, Circle } from 'lucide-react'; +import { Target, Plus, Trash2, CheckCircle2, Circle, Repeat } from 'lucide-react'; import { Milestone, Task } from '@/types/project-milestone'; export default function ProjectMilestones() { @@ -14,6 +14,9 @@ export default function ProjectMilestones() { const [milestoneForm, setMilestoneForm] = useState({ name: '', description: '', dueDate: '' }); const [taskInputs, setTaskInputs] = useState>({}); + const [taskRecurrenceInputs, setTaskRecurrenceInputs] = useState>({}); + const [taskRecurrenceIntervals, setTaskRecurrenceIntervals] = useState>({}); + const [taskRecurrenceEnds, setTaskRecurrenceEnds] = useState>({}); useEffect(() => { setIsClient(true); @@ -65,10 +68,17 @@ export default function ProjectMilestones() { const title = taskInputs[milestoneId]?.trim(); if (!title) return; + const recType = taskRecurrenceInputs[milestoneId]; + const recurrence_config = (recType && recType !== 'none') ? { + type: recType, + intervalDays: recType === 'custom' ? (taskRecurrenceIntervals[milestoneId] || 1) : undefined, + endsAfter: taskRecurrenceEnds[milestoneId] || undefined + } : null; + const res = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, milestoneId }) + body: JSON.stringify({ title, milestoneId, recurrence_config }) }); if (res.ok) { @@ -81,6 +91,9 @@ export default function ProjectMilestones() { return m; })); setTaskInputs(prev => ({ ...prev, [milestoneId]: '' })); + setTaskRecurrenceInputs(prev => ({ ...prev, [milestoneId]: 'none' })); + setTaskRecurrenceIntervals(prev => ({ ...prev, [milestoneId]: 1 })); + setTaskRecurrenceEnds(prev => ({ ...prev, [milestoneId]: 0 })); } }; @@ -229,7 +242,10 @@ export default function ProjectMilestones() {
  • handleToggleTask(task.id, task.completed)}> {task.completed ? : } - {task.title} + + {task.title} + {task.recurrence_config && } +
)} -
- 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' }} - /> - +
+
+ 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' }} + /> + +
+
+ + + {taskRecurrenceInputs[m.id] === 'custom' && ( +
+ Every + setTaskRecurrenceIntervals(prev => ({ ...prev, [m.id]: parseInt(e.target.value) || 1 }))} + style={{ width: '50px', padding: '2px 4px', borderRadius: '4px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)' }} + /> + days +
+ )} + + {(taskRecurrenceInputs[m.id] && taskRecurrenceInputs[m.id] !== 'none') && ( +
+ Ends after (optional): + setTaskRecurrenceEnds(prev => ({ ...prev, [m.id]: parseInt(e.target.value) || 0 }))} + style={{ width: '50px', padding: '2px 4px', borderRadius: '4px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)' }} + /> + times +
+ )} +
diff --git a/src/types/project-milestone.ts b/src/types/project-milestone.ts index ab4d769f5..0b1d1b1eb 100644 --- a/src/types/project-milestone.ts +++ b/src/types/project-milestone.ts @@ -1,3 +1,9 @@ +export interface Recurrence { + type: 'daily' | 'weekly' | 'monthly' | 'custom'; + intervalDays?: number; + endsAfter?: number; +} + export interface Task { id: string; title: string; @@ -6,6 +12,8 @@ export interface Task { priority: string; dueDate?: string; tags: string[]; + recurrence_config?: Recurrence; + recurrence_count?: number; } export interface Milestone { diff --git a/supabase/migrations/20260724193933_add_task_recurrence.sql b/supabase/migrations/20260724193933_add_task_recurrence.sql new file mode 100644 index 000000000..213d0e56b --- /dev/null +++ b/supabase/migrations/20260724193933_add_task_recurrence.sql @@ -0,0 +1,3 @@ +alter table tasks +add column if not exists recurrence_config jsonb, +add column if not exists recurrence_count integer not null default 0; diff --git a/supabase/schema.sql b/supabase/schema.sql index d1ec1323d..57300de2a 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -347,6 +347,8 @@ create table if not exists tasks ( priority text not null default 'medium', due_date timestamptz, tags text[] not null default '{}'::text[], + recurrence_config jsonb, + recurrence_count integer not null default 0, milestone_id text, created_at timestamptz default now(), updated_at timestamptz default now() From e27ba31c6e797f987fc5de2656f05a39f7807d31 Mon Sep 17 00:00:00 2001 From: DESIREDDY MOHITH REDDY Date: Sat, 25 Jul 2026 14:13:27 +0530 Subject: [PATCH 12/12] feat(kanban): drag-and-drop task reordering in board view --- src/app/api/tasks/reorder/route.ts | 47 ++++ src/components/KanbanBoard.tsx | 254 ++++++++++++++++++ .../dashboard/CustomizableDashboard.tsx | 11 +- src/lib/dashboard-layout.ts | 12 +- src/types/project-milestone.ts | 1 + .../20260724195152_add_task_order_index.sql | 2 + supabase/schema.sql | 1 + 7 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 src/app/api/tasks/reorder/route.ts create mode 100644 src/components/KanbanBoard.tsx create mode 100644 supabase/migrations/20260724195152_add_task_order_index.sql diff --git a/src/app/api/tasks/reorder/route.ts b/src/app/api/tasks/reorder/route.ts new file mode 100644 index 000000000..b9630cbeb --- /dev/null +++ b/src/app/api/tasks/reorder/route.ts @@ -0,0 +1,47 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase-admin"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export async function PUT(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); + + try { + const { updates } = await req.json(); + if (!Array.isArray(updates) || updates.length === 0) { + return new Response("Invalid updates payload", { status: 400 }); + } + + // Process updates concurrently or in batch. + // Supabase JS doesn't have a single bulk update method, so we run multiple updates. + const promises = updates.map(async (update: any) => { + const { id, order_index, status } = update; + const dataToUpdate: any = { order_index }; + if (status !== undefined) { + dataToUpdate.status = status; + dataToUpdate.completed = status === 'done'; + } + + return supabaseAdmin + .from("tasks") + .update(dataToUpdate) + .eq("id", id) + .eq("user_id", appUser.id); + }); + + await Promise.all(promises); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +} diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 000000000..5f1066cf4 --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + DndContext, + closestCorners, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragOverEvent, + DragEndEvent, + defaultDropAnimationSideEffects, + DropAnimation +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Task } from '@/types/project-milestone'; +import { KanbanSquare, Plus, MoreHorizontal } from 'lucide-react'; + +const COLUMNS = [ + { id: 'todo', title: 'To Do', color: '#64748b' }, + { id: 'in-progress', title: 'In Progress', color: '#3b82f6' }, + { id: 'done', title: 'Done', color: '#10b981' } +]; + +function SortableTask({ task }: { task: Task }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: task.id, data: { type: 'Task', task } }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
{task.title}
+
+ + {task.priority} + + {task.dueDate && {new Date(task.dueDate).toLocaleDateString()}} +
+
+ ); +} + +export default function KanbanBoard() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTask, setActiveTask] = useState(null); + + useEffect(() => { + fetch('/api/tasks') + .then(res => res.json()) + .then(data => { + // Sort by order_index primarily + const sorted = (Array.isArray(data) ? data : []).sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); + setTasks(sorted); + setLoading(false); + }); + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 3 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const getTasksByStatus = (status: string) => { + return tasks.filter(t => (t.status || (t.completed ? 'done' : 'todo')) === status); + }; + + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + const task = tasks.find(t => t.id === active.id); + if (task) setActiveTask(task); + }; + + const handleDragOver = (event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + + const activeId = active.id; + const overId = over.id; + + if (activeId === overId) return; + + const isActiveTask = active.data.current?.type === 'Task'; + const isOverTask = over.data.current?.type === 'Task'; + const isOverColumn = over.data.current?.type === 'Column'; + + if (!isActiveTask) return; + + // Dropping a task over another task + if (isActiveTask && isOverTask) { + setTasks(prev => { + const activeIndex = prev.findIndex(t => t.id === activeId); + const overIndex = prev.findIndex(t => t.id === overId); + + if (prev[activeIndex].status !== prev[overIndex].status) { + const newTasks = [...prev]; + newTasks[activeIndex] = { ...newTasks[activeIndex], status: newTasks[overIndex].status }; + return arrayMove(newTasks, activeIndex, overIndex); + } + return arrayMove(prev, activeIndex, overIndex); + }); + } + + // Dropping a task over an empty column area + if (isActiveTask && isOverColumn) { + setTasks(prev => { + const activeIndex = prev.findIndex(t => t.id === activeId); + const newTasks = [...prev]; + newTasks[activeIndex] = { ...newTasks[activeIndex], status: String(overId) }; + return arrayMove(newTasks, activeIndex, activeIndex); + }); + } + }; + + const handleDragEnd = async (event: DragEndEvent) => { + setActiveTask(null); + const { active, over } = event; + if (!over) return; + + const activeId = active.id; + const overId = over.id; + const activeIndex = tasks.findIndex(t => t.id === activeId); + + // We already optimistically updated the tasks state during drag over, + // but drag end means we finalize the new order in state and persist. + + let newTasks = [...tasks]; + if (activeId !== overId) { + const overIndex = tasks.findIndex(t => t.id === overId); + if (overIndex !== -1) { + newTasks = arrayMove(newTasks, activeIndex, overIndex); + } + } + + // Re-assign order_index based on visual layout for affected column(s) + const activeTaskFinal = newTasks.find(t => t.id === activeId); + if (!activeTaskFinal) return; + + const targetStatus = activeTaskFinal.status; + + // Update state to lock in the visual order_index + const updatedTasks = newTasks.map((t, idx) => ({ ...t, order_index: idx })); + setTasks(updatedTasks); + + // Filter tasks that need saving (we can just save the whole column for simplicity, + // or batch save everything that changed, but saving everything in the same status is safe). + const columnTasks = updatedTasks.filter(t => t.status === targetStatus); + const updates = columnTasks.map((t, idx) => ({ + id: t.id, + status: t.status, + order_index: idx + })); + + // Fire off API request to persist order + fetch('/api/tasks/reorder', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates }) + }); + }; + + const dropAnimation: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: '0.4' } } }), + }; + + return ( +
+
+ +

Kanban Board

+
+ + {loading ? ( +
Loading tasks...
+ ) : ( + +
+ {COLUMNS.map(col => { + const columnTasks = getTasksByStatus(col.id); + + return ( +
+
+
+
+

{col.title}

+ + {columnTasks.length} + +
+ +
+ +
+ t.id)} + strategy={verticalListSortingStrategy} + > + {columnTasks.map(t => ( + + ))} + +
+
+ ); + })} +
+ + )} +
+ ); +} diff --git a/src/components/dashboard/CustomizableDashboard.tsx b/src/components/dashboard/CustomizableDashboard.tsx index ee4a8a95f..0a838818f 100644 --- a/src/components/dashboard/CustomizableDashboard.tsx +++ b/src/components/dashboard/CustomizableDashboard.tsx @@ -224,6 +224,10 @@ const ProjectMilestones = dynamic( () => import("@/components/ProjectMilestones"), { ssr: false, loading: () => }, ); +const KanbanBoard = dynamic( + () => import("@/components/KanbanBoard"), + { ssr: false, loading: () => }, +); const SECTION_ANCHOR_IDS: Record = { overview: "overview", @@ -484,7 +488,12 @@ const renderDashboardWidget = (widgetId: DashboardWidgetId): ReactNode => { ); - + case "kanban-board": + return ( + + + + ); default: return null; } diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index fe42e76e4..4afab1967 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -41,7 +41,15 @@ export type DashboardWidgetId = | "language-breakdown" | "friend-comparison" | "achievement-progress" - | "project-milestones"; + | "project-milestones" + | "kanban-board"; + +export interface DashboardWidget { + id: DashboardWidgetId; + title: string; + defaultSize: { w: number; h: number }; + minSize?: { w: number; h: number }; +} export interface DashboardLayoutPreference { version: 1; @@ -100,6 +108,7 @@ export const DASHBOARD_WIDGET_LABELS: Record = { "friend-comparison": "Friend Comparison", "achievement-progress": "Achievement Progress", "project-milestones": "Project Milestones", + "kanban-board": "Kanban Board", }; export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { @@ -142,6 +151,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { "friend-comparison", "achievement-progress", "project-milestones", + "kanban-board", ], }, hidden: [], diff --git a/src/types/project-milestone.ts b/src/types/project-milestone.ts index 0b1d1b1eb..d7b46316a 100644 --- a/src/types/project-milestone.ts +++ b/src/types/project-milestone.ts @@ -14,6 +14,7 @@ export interface Task { tags: string[]; recurrence_config?: Recurrence; recurrence_count?: number; + order_index?: number; } export interface Milestone { diff --git a/supabase/migrations/20260724195152_add_task_order_index.sql b/supabase/migrations/20260724195152_add_task_order_index.sql new file mode 100644 index 000000000..f54bf0ba9 --- /dev/null +++ b/supabase/migrations/20260724195152_add_task_order_index.sql @@ -0,0 +1,2 @@ +alter table tasks +add column if not exists order_index integer not null default 0; diff --git a/supabase/schema.sql b/supabase/schema.sql index 57300de2a..2eef4eccf 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -349,6 +349,7 @@ create table if not exists tasks ( tags text[] not null default '{}'::text[], recurrence_config jsonb, recurrence_count integer not null default 0, + order_index integer not null default 0, milestone_id text, created_at timestamptz default now(), updated_at timestamptz default now()