From f33911212d8aa44d4eed9ba65dfcaf9d9d55931a Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Tue, 14 Jul 2026 02:39:50 +0530 Subject: [PATCH] Add time-tracking feature with tasks, time entries, and dashboard widgets. Adds complete time-tracking functionality including: - Supabase migration for tasks and time_entries tables with proper indexes - API routes for task CRUD and time entry management with server-side duration calculation - TaskTimeTracker component with start/stop timer, session history, and localStorage persistence across page refreshes - DailyTimeWidget showing 7-day aggregated time logged as a bar chart - Dashboard integration with new widget types and responsive layout --- src/app/api/tasks/[id]/route.ts | 37 ++ src/app/api/tasks/[id]/time-entries/route.ts | 101 +++++ src/app/api/tasks/route.ts | 86 ++++ src/app/api/time-entries/[id]/route.ts | 37 ++ src/app/api/time-entries/summary/route.ts | 56 +++ src/components/DailyTimeWidget.tsx | 155 +++++++ src/components/TaskTimeTracker.tsx | 421 ++++++++++++++++++ .../dashboard/CustomizableDashboard.tsx | 21 + src/lib/dashboard-layout.ts | 8 +- .../20260517000000_add_time_tracking.sql | 25 ++ 10 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 src/app/api/tasks/[id]/route.ts create mode 100644 src/app/api/tasks/[id]/time-entries/route.ts create mode 100644 src/app/api/tasks/route.ts create mode 100644 src/app/api/time-entries/[id]/route.ts create mode 100644 src/app/api/time-entries/summary/route.ts create mode 100644 src/components/DailyTimeWidget.tsx create mode 100644 src/components/TaskTimeTracker.tsx create mode 100644 supabase/migrations/20260517000000_add_time_tracking.sql diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 000000000..7387d87dc --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -0,0 +1,37 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export async function DELETE( + _req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Only delete if the task belongs to the authenticated user. Associated + // time_entries are removed via the ON DELETE CASCADE foreign key. + const { error } = await supabaseAdmin + .from("tasks") + .delete() + .eq("id", params.id) + .eq("user_id", user.id); + + if (error) { + return Response.json({ error: "Failed to delete task" }, { status: 500 }); + } + + return Response.json({ success: true }, { status: 200 }); +} diff --git a/src/app/api/tasks/[id]/time-entries/route.ts b/src/app/api/tasks/[id]/time-entries/route.ts new file mode 100644 index 000000000..a56099ef5 --- /dev/null +++ b/src/app/api/tasks/[id]/time-entries/route.ts @@ -0,0 +1,101 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +async function getAuthenticatedUserAndTask(taskId: string) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + const { data: task } = await supabaseAdmin + .from("tasks") + .select("id") + .eq("id", taskId) + .eq("user_id", user.id) + .single(); + + if (!task) { + return { error: Response.json({ error: "Task not found" }, { status: 404 }) }; + } + + return { userId: user.id as string }; +} + +export async function GET( + _req: Request, + { params }: { params: { id: string } } +) { + const result = await getAuthenticatedUserAndTask(params.id); + if (result.error) return result.error; + + const { data: entries, error } = await supabaseAdmin + .from("time_entries") + .select("*") + .eq("task_id", params.id) + .eq("user_id", result.userId) + .order("started_at", { ascending: false }); + + if (error) { + return Response.json({ error: "Failed to load time entries" }, { status: 500 }); + } + + return Response.json({ entries: entries ?? [] }); +} + +export async function POST( + req: Request, + { params }: { params: { id: string } } +) { + const result = await getAuthenticatedUserAndTask(params.id); + if (result.error) return result.error; + + const body = (await req.json()) as { startedAt?: string; endedAt?: string }; + + if (!body.startedAt || !body.endedAt) { + return Response.json({ error: "startedAt and endedAt required" }, { status: 400 }); + } + + const startedAt = new Date(body.startedAt); + const endedAt = new Date(body.endedAt); + + if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) { + return Response.json({ error: "Invalid date value" }, { status: 400 }); + } + + if (endedAt <= startedAt) { + return Response.json({ error: "endedAt must be after startedAt" }, { status: 400 }); + } + + // Duration is computed server-side from the timestamps, never trusted + // from the client, so a manipulated request can't inflate logged time. + const durationMinutes = Math.round((endedAt.getTime() - startedAt.getTime()) / 60000); + + const { data: entry, error } = await supabaseAdmin + .from("time_entries") + .insert({ + task_id: params.id, + user_id: result.userId, + started_at: startedAt.toISOString(), + ended_at: endedAt.toISOString(), + duration_minutes: durationMinutes, + }) + .select() + .single(); + + if (error) return Response.json({ error: error.message }, { status: 500 }); + + return Response.json({ entry }, { status: 201 }); +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 000000000..e7b63119d --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,86 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { data: tasks, error: tasksError } = await supabaseAdmin + .from("tasks") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + if (tasksError) { + return Response.json({ error: "Failed to load tasks" }, { status: 500 }); + } + + const { data: entries, error: entriesError } = await supabaseAdmin + .from("time_entries") + .select("task_id, duration_minutes") + .eq("user_id", user.id); + + if (entriesError) { + return Response.json({ error: "Failed to load time entries" }, { status: 500 }); + } + + const totalsByTask = new Map(); + for (const entry of entries ?? []) { + const current = totalsByTask.get(entry.task_id) ?? 0; + totalsByTask.set(entry.task_id, current + entry.duration_minutes); + } + + const tasksWithTotals = (tasks ?? []).map((task) => ({ + ...task, + totalMinutes: totalsByTask.get(task.id) ?? 0, + })); + + return Response.json({ tasks: tasksWithTotals }); +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await req.json()) as { title?: string }; + + if (!body.title || !body.title.trim()) { + return Response.json({ error: "title required" }, { status: 400 }); + } + + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .insert({ + user_id: user.id, + title: body.title.trim(), + }) + .select() + .single(); + + if (error) return Response.json({ error: error.message }, { status: 500 }); + + return Response.json({ task: { ...task, totalMinutes: 0 } }, { status: 201 }); +} diff --git a/src/app/api/time-entries/[id]/route.ts b/src/app/api/time-entries/[id]/route.ts new file mode 100644 index 000000000..dc2bb910c --- /dev/null +++ b/src/app/api/time-entries/[id]/route.ts @@ -0,0 +1,37 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export async function DELETE( + _req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Only delete if the entry belongs to the authenticated user, so a + // session ID from another account can't be used to delete their logs. + const { error } = await supabaseAdmin + .from("time_entries") + .delete() + .eq("id", params.id) + .eq("user_id", user.id); + + if (error) { + return Response.json({ error: "Failed to delete time entry" }, { status: 500 }); + } + + return Response.json({ success: true }, { status: 200 }); +} diff --git a/src/app/api/time-entries/summary/route.ts b/src/app/api/time-entries/summary/route.ts new file mode 100644 index 000000000..9b4d0cb97 --- /dev/null +++ b/src/app/api/time-entries/summary/route.ts @@ -0,0 +1,56 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +const DAYS_IN_SUMMARY = 7; + +function dateKey(iso: string): string { + return iso.slice(0, 10); // "YYYY-MM-DD" in UTC +} + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const since = new Date(); + since.setUTCDate(since.getUTCDate() - (DAYS_IN_SUMMARY - 1)); + since.setUTCHours(0, 0, 0, 0); + + const { data: entries, error } = await supabaseAdmin + .from("time_entries") + .select("started_at, duration_minutes") + .eq("user_id", user.id) + .gte("started_at", since.toISOString()); + + if (error) { + return Response.json({ error: "Failed to load time entries" }, { status: 500 }); + } + + const totalsByDay = new Map(); + for (const entry of entries ?? []) { + const key = dateKey(entry.started_at); + totalsByDay.set(key, (totalsByDay.get(key) ?? 0) + entry.duration_minutes); + } + + const days: { date: string; totalMinutes: number }[] = []; + for (let i = DAYS_IN_SUMMARY - 1; i >= 0; i -= 1) { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + days.push({ date: key, totalMinutes: totalsByDay.get(key) ?? 0 }); + } + + return Response.json({ days }); +} diff --git a/src/components/DailyTimeWidget.tsx b/src/components/DailyTimeWidget.tsx new file mode 100644 index 000000000..d01a80eaf --- /dev/null +++ b/src/components/DailyTimeWidget.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { toast } from "sonner"; +import { SkeletonBlock } from "./WidgetSkeleton"; + +interface DaySummary { + date: string; // "YYYY-MM-DD" + totalMinutes: number; +} + +interface ChartDay { + label: string; + minutes: number; +} + +function formatDayLabel(dateStr: string): string { + const date = new Date(`${dateStr}T00:00:00Z`); + return date.toLocaleDateString(undefined, { weekday: "short", timeZone: "UTC" }); +} + +function formatMinutesLabel(minutes: number): string { + if (minutes === 0) return "0m"; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return hours === 0 ? `${mins}m` : `${hours}h ${mins}m`; +} + +export default function DailyTimeWidget() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSummary = useCallback(() => { + setLoading(true); + setError(null); + fetch("/api/time-entries/summary") + .then((r) => r.json()) + .then((res: { days: DaySummary[] }) => { + const days = res.days ?? []; + setData( + days.map((d) => ({ + label: formatDayLabel(d.date), + minutes: d.totalMinutes, + })) + ); + }) + .catch((err) => { + console.error("Failed to fetch daily time summary:", err); + setError("We couldn't load your time tracking summary right now."); + toast.error("Failed to load time tracking summary"); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchSummary(); + }, [fetchSummary]); + + const totalMinutes = data.reduce((sum, d) => sum + d.minutes, 0); + + return ( +
+
+

+ Time Logged (Last 7 Days) +

+
+ +

+ {!loading && !error && totalMinutes > 0 && `${formatMinutesLabel(totalMinutes)} total this week`} +

+ +
+ {loading ? ( +
+ Loading time tracking summary + {[1, 2, 3, 4, 5, 6, 7].map((i) => ( + + ))} +
+ ) : error ? ( +
+
+

{error}

+ +
+
+ ) : totalMinutes === 0 ? ( +
+

+ No time logged in the last 7 days. +

+
+ ) : ( + + + + + + [formatMinutesLabel(value), "Logged"]} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + color: "var(--card-foreground)", + borderRadius: "0.5rem", + fontSize: "0.875rem", + }} + itemStyle={{ color: "var(--accent)" }} + /> + + + + )} +
+
+ ); +} diff --git a/src/components/TaskTimeTracker.tsx b/src/components/TaskTimeTracker.tsx new file mode 100644 index 000000000..e12a68a1e --- /dev/null +++ b/src/components/TaskTimeTracker.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +interface Task { + id: string; + title: string; + created_at: string; + totalMinutes: number; +} + +interface TimeEntry { + id: string; + task_id: string; + started_at: string; + ended_at: string; + duration_minutes: number; +} + +interface ActiveTimer { + taskId: string; + startedAt: string; // ISO 8601 +} + +const ACTIVE_TIMER_STORAGE_KEY = "devtrack_active_timer"; + +function formatDuration(totalMinutes: number): string { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${minutes}m`; + return `${hours}h ${minutes}m`; +} + +function formatElapsed(startedAt: string, nowMs: number): string { + const elapsedSeconds = Math.max( + 0, + Math.floor((nowMs - new Date(startedAt).getTime()) / 1000) + ); + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 + ? `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` + : `${pad(minutes)}:${pad(seconds)}`; +} + +function readStoredTimer(): ActiveTimer | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(ACTIVE_TIMER_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as ActiveTimer; + if (!parsed.taskId || !parsed.startedAt) return null; + return parsed; + } catch { + return null; + } +} + +function writeStoredTimer(timer: ActiveTimer | null) { + if (typeof window === "undefined") return; + if (timer) { + window.localStorage.setItem(ACTIVE_TIMER_STORAGE_KEY, JSON.stringify(timer)); + } else { + window.localStorage.removeItem(ACTIVE_TIMER_STORAGE_KEY); + } +} + +export default function TaskTimeTracker() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [newTaskTitle, setNewTaskTitle] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [confirmingId, setConfirmingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const [activeTimer, setActiveTimer] = useState(null); + const [now, setNow] = useState(Date.now()); + const stoppingRef = useRef(false); + + const [expandedTaskId, setExpandedTaskId] = useState(null); + const [entriesByTask, setEntriesByTask] = useState>({}); + const [entriesLoading, setEntriesLoading] = useState(false); + const [confirmingEntryId, setConfirmingEntryId] = useState(null); + const [deletingEntryId, setDeletingEntryId] = useState(null); + + const loadTasks = useCallback(async () => { + const response = await fetch("/api/tasks"); + const data: { tasks: Task[] } = await response.json(); + setTasks(data.tasks ?? []); + }, []); + + // Resume a timer that was running before a page refresh. + useEffect(() => { + setActiveTimer(readStoredTimer()); + loadTasks() + .catch(() => {}) + .finally(() => setLoading(false)); + }, [loadTasks]); + + // Tick every second while a timer is running so the elapsed display updates. + useEffect(() => { + if (!activeTimer) return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [activeTimer]); + + const stopActiveTimer = useCallback(async () => { + if (!activeTimer || stoppingRef.current) return; + stoppingRef.current = true; + + const { taskId, startedAt } = activeTimer; + const endedAt = new Date().toISOString(); + + setActiveTimer(null); + writeStoredTimer(null); + + try { + await fetch(`/api/tasks/${taskId}/time-entries`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ startedAt, endedAt }), + }); + await loadTasks().catch(() => {}); + if (expandedTaskId === taskId) { + await loadEntriesForTask(taskId).catch(() => {}); + } + } finally { + stoppingRef.current = false; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTimer, loadTasks, expandedTaskId]); + + async function startTimer(taskId: string) { + if (activeTimer) { + await stopActiveTimer(); + } + const timer: ActiveTimer = { taskId, startedAt: new Date().toISOString() }; + setActiveTimer(timer); + writeStoredTimer(timer); + setNow(Date.now()); + } + + async function loadEntriesForTask(taskId: string) { + setEntriesLoading(true); + try { + const response = await fetch(`/api/tasks/${taskId}/time-entries`); + const data: { entries: TimeEntry[] } = await response.json(); + setEntriesByTask((prev) => ({ ...prev, [taskId]: data.entries ?? [] })); + } finally { + setEntriesLoading(false); + } + } + + async function toggleLogPanel(taskId: string) { + if (expandedTaskId === taskId) { + setExpandedTaskId(null); + return; + } + setExpandedTaskId(taskId); + if (!entriesByTask[taskId]) { + await loadEntriesForTask(taskId).catch(() => {}); + } + } + + async function handleDeleteEntry(taskId: string, entryId: string) { + const previous = entriesByTask[taskId] ?? []; + setEntriesByTask((prev) => ({ + ...prev, + [taskId]: previous.filter((e) => e.id !== entryId), + })); + setConfirmingEntryId(null); + setDeletingEntryId(entryId); + + try { + const res = await fetch(`/api/time-entries/${entryId}`, { method: "DELETE" }); + if (!res.ok) { + setEntriesByTask((prev) => ({ ...prev, [taskId]: previous })); + } else { + await loadTasks().catch(() => {}); + } + } catch { + setEntriesByTask((prev) => ({ ...prev, [taskId]: previous })); + } finally { + setDeletingEntryId(null); + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setCreateError(null); + + try { + const response = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: newTaskTitle }), + }); + + if (!response.ok) { + throw new Error("Failed to create task"); + } + } catch { + setCreateError("Failed to create task. Please try again."); + setCreating(false); + return; + } + + setNewTaskTitle(""); + await loadTasks().catch(() => {}); + setCreating(false); + } + + async function handleDeleteTask(id: string) { + if (activeTimer?.taskId === id) { + await stopActiveTimer(); + } + + const previousTasks = tasks; + setTasks((prev) => prev.filter((t) => t.id !== id)); + setConfirmingId(null); + setDeletingId(id); + + try { + const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" }); + if (!res.ok) { + setTasks(previousTasks); + } + } catch { + setTasks(previousTasks); + } finally { + setDeletingId(null); + } + } + + if (loading) { + return ( +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+

Time Tracking

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

+ No tasks yet. Add one below to start tracking time. +

+ ) : ( +
    + {tasks.map((task) => { + const isRunning = activeTimer?.taskId === task.id; + const isConfirming = confirmingId === task.id; + const isDeleting = deletingId === task.id; + const isExpanded = expandedTaskId === task.id; + const entries = entriesByTask[task.id] ?? []; + + return ( +
  • +
    +
    +

    {task.title}

    +

    + {isRunning + ? formatElapsed(activeTimer.startedAt, now) + : formatDuration(task.totalMinutes)}{" "} + logged +

    +
    + +
    + + + + + {isConfirming ? ( + + + / + + + ) : ( + + )} +
    +
    + + {isExpanded && ( +
    + {entriesLoading && !entriesByTask[task.id] ? ( +

    Loading sessions...

    + ) : entries.length === 0 ? ( +

    No sessions logged yet.

    + ) : ( +
      + {entries.map((entry) => ( +
    • + + {new Date(entry.started_at).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}{" "} + · {formatDuration(entry.duration_minutes)} + + {confirmingEntryId === entry.id ? ( + + + / + + + ) : ( + + )} +
    • + ))} +
    + )} +
    + )} +
  • + ); + })} +
+ )} + +
+ setNewTaskTitle(e.target.value)} + placeholder="Add a task to track" + required + disabled={creating} + className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition placeholder:text-[var(--muted-foreground)] focus:border-[var(--accent)]" + /> + +
+ + {createError &&

{createError}

} +
+ ); +} diff --git a/src/components/dashboard/CustomizableDashboard.tsx b/src/components/dashboard/CustomizableDashboard.tsx index 26daa9d63..c12a5017a 100644 --- a/src/components/dashboard/CustomizableDashboard.tsx +++ b/src/components/dashboard/CustomizableDashboard.tsx @@ -24,6 +24,7 @@ import LazyWidget from "@/components/LazyWidget"; import DiscussionsWidget from "@/components/DiscussionsWidget"; import CommunityMetrics from "@/components/CommunityMetrics"; import GoalTracker from "@/components/GoalTracker"; +import TaskTimeTracker from "@/components/TaskTimeTracker"; import StreakTracker from "@/components/StreakTracker"; import ConsistencyScoreWidget from "@/components/ConsistencyScoreWidget"; import TopRepos from "@/components/TopRepos"; @@ -198,6 +199,11 @@ const CommitTimeChart = dynamic(() => import("@/components/CommitTimeChart"), { loading: () => , }); +const DailyTimeWidget = dynamic(() => import("@/components/DailyTimeWidget"), { + ssr: false, + loading: () => , +}); + const PRReviewTrendChart = dynamic( () => import("@/components/PRReviewTrendChart"), { ssr: false, loading: () => }, @@ -250,6 +256,7 @@ const WIDGET_SPAN_CLASSES: Partial> = { "repo-analytics": "lg:col-span-2", "issue-metrics": "xl:col-span-2", "goal-tracker": "xl:col-span-2", + "time-tracker": "xl:col-span-2", "daily-note": "xl:col-span-2", "recent-activity": "xl:col-span-2", "sponsor-analytics": "xl:col-span-2", @@ -425,6 +432,20 @@ const renderDashboardWidget = (widgetId: DashboardWidgetId): ReactNode => { ); + case "time-tracker": + return ( + + + + ); + + case "time-tracking-summary": + return ( + }> + + + ); + case "daily-note": return ; diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 3f072e201..1afa57a3c 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -40,7 +40,9 @@ export type DashboardWidgetId = | "ci-analytics" | "language-breakdown" | "friend-comparison" - | "achievement-progress"; + | "achievement-progress" + | "time-tracker" + | "time-tracking-summary"; export interface DashboardLayoutPreference { version: 1; @@ -98,6 +100,8 @@ export const DASHBOARD_WIDGET_LABELS: Record = { "language-breakdown": "Language Breakdown", "friend-comparison": "Friend Comparison", "achievement-progress": "Achievement Progress", + "time-tracker": "Time Tracking", + "time-tracking-summary": "Time Logged (Last 7 Days)", }; export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { @@ -133,6 +137,8 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { goals: [ "issue-metrics", "goal-tracker", + "time-tracker", + "time-tracking-summary", "daily-note", "recent-activity", "ci-analytics", diff --git a/supabase/migrations/20260517000000_add_time_tracking.sql b/supabase/migrations/20260517000000_add_time_tracking.sql new file mode 100644 index 000000000..0748b969d --- /dev/null +++ b/supabase/migrations/20260517000000_add_time_tracking.sql @@ -0,0 +1,25 @@ +-- Migration: add tasks and time_entries tables +-- Supports per-task Start/Stop time tracking with session history. + +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, + created_at timestamptz default now() +); + +create index if not exists tasks_user on tasks(user_id); + +create table if not exists time_entries ( + id text primary key default gen_random_uuid()::text, + task_id text not null references tasks(id) on delete cascade, + user_id text not null references users(id) on delete cascade, + started_at timestamptz not null, + ended_at timestamptz not null, + duration_minutes integer not null, + created_at timestamptz default now(), + constraint time_entries_ended_after_started check (ended_at > started_at) +); + +create index if not exists time_entries_task on time_entries(task_id); +create index if not exists time_entries_user_started on time_entries(user_id, started_at);