diff --git a/src/app/api/local-coding/keys/route.ts b/src/app/api/local-coding/keys/route.ts new file mode 100644 index 00000000..56caddf4 --- /dev/null +++ b/src/app/api/local-coding/keys/route.ts @@ -0,0 +1,108 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { randomBytes, createHash } from "crypto"; + +export const dynamic = "force-dynamic"; + +function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { data: keys } = await supabaseAdmin + .from("local_coding_api_keys") + .select("id, name, last_used_at, created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + return Response.json({ keys: keys || [] }); +} + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + let body: { name?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const name = body.name?.trim(); + if (!name) { + return Response.json({ error: "Name is required" }, { status: 400 }); + } + + const apiKey = randomBytes(24).toString("base64url"); + const apiKeyHash = hashApiKey(apiKey); + + const { data: keyRecord, error } = await supabaseAdmin + .from("local_coding_api_keys") + .insert({ + user_id: user.id, + api_key: apiKeyHash, + name, + }) + .select("id, name, last_used_at, created_at") + .single(); + + if (error) { + console.error("Error creating API key:", error); + return Response.json( + { error: "Failed to create API key" }, + { status: 500 } + ); + } + + return Response.json({ + key: { ...keyRecord, api_key: apiKey }, + message: "Store this API key securely. It will not be shown again.", + }); +} + +export async function DELETE(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { searchParams } = new URL(req.url); + const keyId = searchParams.get("id"); + + if (!keyId) { + return Response.json({ error: "Key ID is required" }, { status: 400 }); + } + + const { error } = await supabaseAdmin + .from("local_coding_api_keys") + .delete() + .eq("id", keyId) + .eq("user_id", user.id); + + if (error) { + console.error("Error deleting API key:", error); + return Response.json({ error: "Failed to delete key" }, { status: 500 }); + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/local-coding/stats/route.ts b/src/app/api/local-coding/stats/route.ts new file mode 100644 index 00000000..24ab971d --- /dev/null +++ b/src/app/api/local-coding/stats/route.ts @@ -0,0 +1,73 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +const ALLOWED_DAYS = [7, 30, 90]; +const DEFAULT_DAYS = 30; + +function validateDays(days: number): number { + if (ALLOWED_DAYS.includes(days)) { + return days; + } + return DEFAULT_DAYS; +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { searchParams } = new URL(req.url); + const rawDays = parseInt(searchParams.get("days") || "30", 10); + const days = validateDays(isNaN(rawDays) ? DEFAULT_DAYS : rawDays); + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - days); + const fromDateStr = fromDate.toISOString().slice(0, 10); + + const { data: sessions } = await supabaseAdmin + .from("local_coding_sessions") + .select("*") + .eq("user_id", user.id) + .gte("date", fromDateStr) + .order("date", { ascending: false }); + + if (!sessions || sessions.length === 0) { + return Response.json({ + dailyData: [], + totals: { + totalSeconds: 0, + totalDays: 0, + avgSecondsPerDay: 0, + }, + hasData: false, + }); + } + + const dailyData = sessions.map((s) => ({ + date: s.date, + totalSeconds: s.total_seconds, + fileCount: s.file_count, + projectCount: s.project_count, + })); + + const totalSeconds = dailyData.reduce((sum, d) => sum + d.totalSeconds, 0); + const totalDays = dailyData.length; + + return Response.json({ + dailyData, + totals: { + totalSeconds, + totalDays, + avgSecondsPerDay: Math.round(totalSeconds / totalDays), + }, + hasData: true, + }); +} diff --git a/src/app/api/local-coding/sync/route.ts b/src/app/api/local-coding/sync/route.ts new file mode 100644 index 00000000..56df39a3 --- /dev/null +++ b/src/app/api/local-coding/sync/route.ts @@ -0,0 +1,167 @@ +import { NextRequest } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase"; +import { createHash } from "crypto"; + +export const dynamic = "force-dynamic"; + +const MAX_SESSIONS_PER_REQUEST = 100; +const MAX_SESSIONS_PER_USER = 365; +const ALLOWED_DAYS = [7, 30, 90]; +const DEFAULT_DAYS = 30; + +function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +async function authenticateApiKey(apiKey: string): Promise { + const keyHash = hashApiKey(apiKey); + + const { data: keyRecord } = await supabaseAdmin + .from("local_coding_api_keys") + .select("user_id") + .eq("api_key_hash", keyHash) + .single(); + + if (!keyRecord) { + return null; + } + + await supabaseAdmin + .from("local_coding_api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("api_key_hash", keyHash); + + return keyRecord.user_id; +} + +function validateDays(days: number): number { + if (ALLOWED_DAYS.includes(days)) { + return days; + } + return DEFAULT_DAYS; +} + +interface SessionData { + date: string; + totalSeconds: number; + fileCount: number; + projectCount: number; +} + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json({ error: "API key required" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + const userId = await authenticateApiKey(apiKey); + + if (!userId) { + return Response.json({ error: "Invalid API key" }, { status: 401 }); + } + + let body: { sessions?: SessionData[] }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const sessions = body.sessions; + if (!sessions || !Array.isArray(sessions) || sessions.length === 0) { + return Response.json( + { error: "Sessions array is required" }, + { status: 400 } + ); + } + + if (sessions.length > MAX_SESSIONS_PER_REQUEST) { + return Response.json( + { error: `Too many sessions. Maximum ${MAX_SESSIONS_PER_REQUEST} per request.` }, + { status: 400 } + ); + } + + const { count: existingCount } = await supabaseAdmin + .from("local_coding_sessions") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId); + + const newSessions = sessions.filter((s) => { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(s.date)) { + return false; + } + if (typeof s.totalSeconds !== "number" || s.totalSeconds < 0) { + return false; + } + return true; + }); + + if (newSessions.length !== sessions.length) { + return Response.json( + { error: "Invalid session data found in array" }, + { status: 400 } + ); + } + + if ((existingCount || 0) + newSessions.length > MAX_SESSIONS_PER_USER) { + return Response.json( + { error: `Session limit reached. Maximum ${MAX_SESSIONS_PER_USER} sessions per user.` }, + { status: 400 } + ); + } + + const records = newSessions.map((session) => ({ + user_id: userId, + date: session.date, + total_seconds: session.totalSeconds, + file_count: session.fileCount || 0, + project_count: session.projectCount || 0, + })); + + for (const record of records) { + await supabaseAdmin + .from("local_coding_sessions") + .upsert(record, { onConflict: "user_id,date" }); + } + + return Response.json({ + success: true, + synced: records.length, + message: "Sessions synced successfully", + }); +} + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json({ error: "API key required" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + const userId = await authenticateApiKey(apiKey); + + if (!userId) { + return Response.json({ error: "Invalid API key" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const rawDays = parseInt(searchParams.get("days") || "30", 10); + const days = validateDays(isNaN(rawDays) ? DEFAULT_DAYS : rawDays); + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - days); + const fromDateStr = fromDate.toISOString().slice(0, 10); + + const { data: sessions } = await supabaseAdmin + .from("local_coding_sessions") + .select("*") + .eq("user_id", userId) + .gte("date", fromDateStr) + .order("date", { ascending: false }); + + return Response.json({ sessions: sessions || [] }); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 333e6d73..86616361 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -18,6 +18,7 @@ import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; +import LocalCodingTime from "@/components/LocalCodingTime"; import { authOptions } from "@/lib/auth"; import { cookies } from "next/headers"; import { getServerSession } from "next-auth"; @@ -57,7 +58,7 @@ export default async function DashboardPage() { - {/* Row 1: Contribution graph + Streak + Friend Comparison */} + {/* Row 1: Contribution graph + Streak + Local Coding Time */}
@@ -68,7 +69,7 @@ export default async function DashboardPage() {
- +
diff --git a/src/components/LocalCodingTime.tsx b/src/components/LocalCodingTime.tsx new file mode 100644 index 00000000..3b88f0b3 --- /dev/null +++ b/src/components/LocalCodingTime.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface DailyData { + date: string; + totalSeconds: number; + fileCount: number; + projectCount: number; +} + +function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export default function LocalCodingTime() { + const [data, setData] = useState<{ + dailyData: DailyData[]; + totals: { + totalSeconds: number; + totalDays: number; + avgSecondsPerDay: number; + }; + hasData: boolean; + } | null>(null); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(30); + + useEffect(() => { + async function loadStats() { + try { + const res = await fetch(`/api/local-coding/stats?days=${days}`); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } catch { + setData(null); + } finally { + setLoading(false); + } + } + + loadStats(); + }, [days]); + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (!data || !data.hasData) { + return ( +
+
+

+ Local Coding Time +

+ +
+
+ + + +

+ No local coding data yet +

+

+ Install the DevTrack VS Code extension to track your coding time +

+
+
+ ); + } + + const maxSeconds = Math.max(...data.dailyData.map((d) => d.totalSeconds), 1); + + return ( +
+
+

+ Local Coding Time +

+ +
+ +
+
+
+ {formatDuration(data.totals.totalSeconds)} +
+
+ Total time +
+
+
+
+ {data.totals.totalDays} +
+
+ Active days +
+
+
+
+ {formatDuration(data.totals.avgSecondsPerDay)} +
+
+ Daily avg +
+
+
+ +
+ {data.dailyData.slice(0, 14).map((day) => { + const pct = (day.totalSeconds / maxSeconds) * 100; + return ( +
+ + {formatDate(day.date)} + +
+
+
+ + {formatDuration(day.totalSeconds)} + +
+ ); + })} +
+ +
+

+ Track your coding time with the DevTrack VS Code extension +

+
+
+ ); +} diff --git a/supabase/migrations/20260521000000_add_local_coding_tables.sql b/supabase/migrations/20260521000000_add_local_coding_tables.sql new file mode 100644 index 00000000..56eabe45 --- /dev/null +++ b/supabase/migrations/20260521000000_add_local_coding_tables.sql @@ -0,0 +1,25 @@ +create table if not exists local_coding_sessions ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + date date not null, + total_seconds integer not null default 0, + file_count integer not null default 0, + project_count integer not null default 0, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique(user_id, date) +); + +create index if not exists local_coding_sessions_user_date on local_coding_sessions(user_id, date); + +create table if not exists local_coding_api_keys ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + api_key text not null unique, + name text not null, + last_used_at timestamptz, + created_at timestamptz default now() +); + +create index if not exists local_coding_api_keys_user on local_coding_api_keys(user_id); +create index if not exists local_coding_api_keys_key on local_coding_api_keys(api_key); diff --git a/supabase/migrations/20260522000000_add_api_key_hash_column.sql b/supabase/migrations/20260522000000_add_api_key_hash_column.sql new file mode 100644 index 00000000..fec3f473 --- /dev/null +++ b/supabase/migrations/20260522000000_add_api_key_hash_column.sql @@ -0,0 +1,3 @@ +alter table local_coding_api_keys add column if not exists api_key_hash text unique; + +create index if not exists local_coding_api_keys_key_hash on local_coding_api_keys(api_key_hash);