diff --git a/src/app/api/metrics/productive-hours/route.ts b/src/app/api/metrics/productive-hours/route.ts new file mode 100644 index 00000000..337ce1a9 --- /dev/null +++ b/src/app/api/metrics/productive-hours/route.ts @@ -0,0 +1,344 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { + getAccountToken, + getAllAccounts, + mergeMetrics, +} from "@/lib/github-accounts"; +import { GITHUB_API, GitHubCommitSearchItem } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +export interface HourlyCell { + day: number; + hour: number; + count: number; + avg: number; +} + +export interface ProductiveHoursResponse { + grid: HourlyCell[]; + peak: HourlyCell | null; + total: number; + days: number; + timezone: string; +} + +async function fetchProductiveHoursForAccount( + token: string, + githubLogin: string, + days: number, + timezone: string, + cacheContext: { bypass: boolean; userId: string }, + fromDate?: string, + repo?: string | null +): Promise { + const repoFilter = repo ? ` repo:${repo}` : ""; + + const key = metricsCacheKey(cacheContext.userId, "productive-hours", { + days, + githubLogin, + timezone, + from: fromDate ?? undefined, + repo, + }); + + return withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS["productive-hours"], // reuse the same TTL + }, + async () => { + const since = new Date(); + since.setDate(since.getDate() - days); + const sinceStr = fromDate ?? toLocalDateStr(since); + + let allItems: GitHubCommitSearchItem[] = []; + let totalCount = 0; + let page = 1; + + // Paginate GitHub commit search — mirrors contributions/route.ts exactly. + // Up to 10 pages × 100 items = 1 000 commits max. + while (page <= 10) { + const searchUrl = new URL(`${GITHUB_API}/search/commits`); + searchUrl.searchParams.set( + "q", + `author:${githubLogin} author-date:>=${sinceStr}${repoFilter}` + ); + searchUrl.searchParams.set("per_page", "100"); + searchUrl.searchParams.set("page", String(page)); + searchUrl.searchParams.set("sort", "author-date"); + searchUrl.searchParams.set("order", "desc"); + + const searchRes = await fetch(searchUrl.toString(), { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + }); + + if (!searchRes.ok) { + // Graceful degradation on rate-limit — return partial data already collected. + if (searchRes.status === 429 || searchRes.status === 403) { + if (allItems.length === 0) { + throw new Error(`GitHub API error: ${searchRes.status}`); + } + break; + } + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + total_count: number; + items: GitHubCommitSearchItem[]; + }; + + if (page === 1) { + totalCount = data.total_count; + } + + allItems = allItems.concat(data.items); + + if ( + data.items.length < 100 || + allItems.length >= 1000 || + allItems.length >= totalCount + ) { + break; + } + + page += 1; + } + + const counts: Record = {}; + + for (const item of allItems) { + const utcDate = new Date(item.commit.author.date); + + const localDate = new Date( + utcDate.toLocaleString("en-US", { timeZone: timezone }) + ); + + const day = localDate.getDay(); // 0 Sun … 6 Sat + const hour = localDate.getHours(); // 0–23 + const k = `${day}-${hour}`; + counts[k] = (counts[k] ?? 0) + 1; + } + + return buildResponse(counts, totalCount, days, timezone); + } + ); +} + +function mergeProductiveHours( + a: ProductiveHoursResponse, + b: ProductiveHoursResponse +): ProductiveHoursResponse { + const counts: Record = {}; + + for (const cell of [...a.grid, ...b.grid]) { + const k = `${cell.day}-${cell.hour}`; + counts[k] = (counts[k] ?? 0) + cell.count; + } + + return buildResponse( + counts, + a.total + b.total, + Math.max(a.days, b.days), + a.timezone + ); +} + +function buildResponse( + counts: Record, + total: number, + days: number, + timezone: string +): ProductiveHoursResponse { + const weeks = Math.max(Math.ceil(days / 7), 1); + + const grid: HourlyCell[] = []; + let peak: HourlyCell | null = null; + + for (let day = 0; day < 7; day++) { + for (let hour = 0; hour < 24; hour++) { + const count = counts[`${day}-${hour}`] ?? 0; + const avg = parseFloat((count / weeks).toFixed(2)); + const cell: HourlyCell = { day, hour, count, avg }; + grid.push(cell); + + if (count > 0 && (!peak || avg > peak.avg)) { + peak = cell; + } + } + } + + return { grid, peak, total, days, timezone }; +} + +function toLocalDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const searchParams = req.nextUrl.searchParams; + + const timezone = sanitizeTimezone(searchParams.get("tz") ?? "UTC"); + + const fromParam = searchParams.get("from"); + const toParam = searchParams.get("to"); + const repoParam = searchParams.get("repo"); + + let days: number; + let fromDate: string | undefined; + + if (fromParam && toParam) { + fromDate = fromParam; + const msPerDay = 1000 * 60 * 60 * 24; + days = + Math.ceil( + (new Date(toParam).getTime() - new Date(fromParam).getTime()) / msPerDay + ) + 1; + } else { + const daysParam = searchParams.get("days"); + const parsedDays = daysParam ? parseInt(daysParam, 10) : NaN; + days = isNaN(parsedDays) ? 90 : Math.max(1, Math.min(365, parsedDays)); + } + + const accountId = searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); + + if (!accountId) { + try { + const result = await fetchProductiveHoursForAccount( + session.accessToken, + session.githubLogin, + days, + timezone, + { bypass, userId: session.githubId ?? session.githubLogin }, + fromDate, + repoParam + ); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (accountId === "combined") { + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + userRow.id + ); + + const results = await Promise.allSettled( + accounts.map((account) => + fetchProductiveHoursForAccount( + account.token, + account.githubLogin, + days, + timezone, + { bypass, userId: account.githubId }, + fromDate, + repoParam + ) + ) + ); + + const merged = mergeMetrics(results, mergeProductiveHours); + + if (!merged) { + return Response.json({ error: "All accounts failed" }, { status: 502 }); + } + + return Response.json(merged); + } + + if (accountId === session.githubId) { + try { + const result = await fetchProductiveHoursForAccount( + session.accessToken, + session.githubLogin, + days, + timezone, + { bypass, userId: session.githubId }, + fromDate, + repoParam + ); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + const accountToken = await getAccountToken(userRow.id, accountId); + + if (!accountToken) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", userRow.id) + .eq("github_id", accountId) + .single(); + + if (!accountRow?.github_login) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + try { + const result = await fetchProductiveHoursForAccount( + accountToken, + accountRow.github_login, + days, + timezone, + { bypass, userId: accountId }, + fromDate, + repoParam + ); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } +} + +function sanitizeTimezone(tz: string): string { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return tz; + } catch { + return "UTC"; + } +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 52816e5a..e810b40a 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import LazyWidget from "@/components/LazyWidget"; +import LazyWidget from "@/components/LazyWidget"; import DiscussionsWidget from "@/components/DiscussionsWidget"; import CommunityMetrics from "@/components/CommunityMetrics"; import GoalTracker from "@/components/GoalTracker"; @@ -108,6 +108,11 @@ const PRReviewTrendChart = dynamic( { ssr: false, loading: () => }, ); +const ProductiveHoursWidget = dynamic( + () => import("@/components/ProductiveHoursWidget"), + { ssr: false, loading: () => }, +); + export default async function DashboardPage() { const session = await getServerSession(authOptions); if (!session) redirect("/"); @@ -200,6 +205,7 @@ export default async function DashboardPage() { }> + diff --git a/src/components/ProductiveHoursWidget.tsx b/src/components/ProductiveHoursWidget.tsx new file mode 100644 index 00000000..4af326ce --- /dev/null +++ b/src/components/ProductiveHoursWidget.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; + +const DEFAULT_DAYS = 90; + +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const DAY_FULL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +const PRESET_RANGES = [ + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, + { label: "6mo", days: 180 }, + { label: "1yr", days: 365 }, +] as const; + +interface HourlyCell { + day: number; + hour: number; + count: number; + avg: number; +} + +interface ProductiveHoursResponse { + grid: HourlyCell[]; + peak: HourlyCell | null; + total: number; + days: number; + timezone: string; +} + +interface TooltipState { + day: number; + hour: number; + avg: number; + col: number; + row: number; +} + +function formatHour(h: number): string { + if (h === 0) return "12am"; + if (h === 12) return "12pm"; + return h < 12 ? `${h}am` : `${h - 12}pm`; +} + +function formatDateKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +export default function ProductiveHoursWidget() { + const { themeConfig, theme, setTheme } = useHeatmapTheme(); + + const [grid, setGrid] = useState([]); + const [peak, setPeak] = useState(null); + const [totalCommits, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [minutesAgo, setMinutesAgo] = useState(0); + + const [tooltip, setTooltip] = useState(null); + const handleClearTooltip = useCallback(() => setTooltip(null), []); + + const [selectedDays, setSelectedDays] = useState(DEFAULT_DAYS); + const [showPopover, setShowPopover] = useState(false); + const [customFrom, setCustomFrom] = useState(""); + const [customTo, setCustomTo] = useState(""); + const [customLabel, setCustomLabel] = useState(null); + const [customError, setCustomError] = useState(null); + const popoverRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + const stored = localStorage.getItem("devtrack:productive-hours-range"); + const valid = ["30", "90", "180", "365"]; + if (stored && valid.includes(stored)) { + setSelectedDays(Number(stored)); + } + } catch { + // localStorage unavailable — use default + } + }, []); + + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width); + }); + ro.observe(el); + setContainerWidth(el.getBoundingClientRect().width); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!showPopover) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setShowPopover(false); + }; + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setShowPopover(false); + } + }; + document.addEventListener("keydown", handleKey); + document.addEventListener("mousedown", handleClick); + return () => { + document.removeEventListener("keydown", handleKey); + document.removeEventListener("mousedown", handleClick); + }; + }, [showPopover]); + + const handleRangeChange = useCallback((newDays: number) => { + setSelectedDays(newDays); + setCustomLabel(null); + setCustomFrom(""); + setCustomTo(""); + setCustomError(null); + try { + localStorage.setItem("devtrack:productive-hours-range", String(newDays)); + } catch {} + }, []); + + const handleCustomApply = useCallback(() => { + setCustomError(null); + const today = new Date().toISOString().slice(0, 10); + + if (!customFrom || !customTo) { + setCustomError("Please select both dates."); + return; + } + if (customFrom > customTo) { + setCustomError("Start date must be before end date."); + return; + } + if (customTo > today) { + setCustomError("End date can't be in the future."); + return; + } + const msPerDay = 1_000 * 60 * 60 * 24; + const diff = (new Date(customTo).getTime() - new Date(customFrom).getTime()) / msPerDay; + if (diff > 365 * 2) { + setCustomError("Max range is 2 years."); + return; + } + + const fmt = (d: string) => { + const [year, month, day] = d.split("-").map(Number); + return new Date(year, month - 1, day).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }; + setCustomLabel(`${fmt(customFrom)} – ${fmt(customTo)}`); + setShowPopover(false); + }, [customFrom, customTo]); + + const { currentFrom, currentTo } = useMemo(() => { + if (customLabel && customFrom && customTo) { + return { currentFrom: customFrom, currentTo: customTo }; + } + const end = new Date(); + const start = new Date(end); + start.setDate(end.getDate() - (selectedDays - 1)); + return { + currentFrom: formatDateKey(start), + currentTo: formatDateKey(end), + }; + }, [customLabel, customFrom, customTo, selectedDays]); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const params = new URLSearchParams({ + from: currentFrom, + to: currentTo, + tz, + }); + + fetch(`/api/metrics/productive-hours?${params.toString()}`) + .then((res) => { + if (!res.ok) throw new Error("API error"); + return res.json(); + }) + .then((data: ProductiveHoursResponse) => { + if (!active) return; + setGrid(data.grid ?? []); + setPeak(data.peak ?? null); + setTotal(data.total ?? 0); + setLastUpdated(new Date()); + setMinutesAgo(0); + }) + .catch(() => { + if (!active) return; + setError("Failed to load productive hours data."); + }) + .finally(() => { + if (!active) return; + setLoading(false); + }); + + return () => { active = false; }; + }, [currentFrom, currentTo]); + + useEffect(() => { + if (!lastUpdated) return; + const id = setInterval(() => { + setMinutesAgo(Math.floor((Date.now() - lastUpdated.getTime()) / 60_000)); + }, 60_000); + return () => clearInterval(id); + }, [lastUpdated]); + + const maxAvg = useMemo( + () => Math.max(...grid.map((c) => c.avg), 1), + [grid] + ); + + const getCellColor = useCallback( + (avg: number): string => { + if (avg === 0) return themeConfig.missed; + const normalized = avg / maxAvg; + if (normalized <= 0.25) return themeConfig.levelOne; + if (normalized <= 0.50) return themeConfig.levelTwo; + if (normalized <= 0.75) return themeConfig.levelThree; + return themeConfig.levelFour; + }, + [maxAvg, themeConfig] + ); + + const cellMap = useMemo(() => { + const m = new Map(); + for (const cell of grid) m.set(`${cell.day}-${cell.hour}`, cell); + return m; + }, [grid]); + + const DAY_LABEL_WIDTH = 32; + const CELL_GAP = 2; + const availableWidth = containerWidth > 0 ? containerWidth - DAY_LABEL_WIDTH - 23 * CELL_GAP : 0; + const CELL_SIZE = Math.max(10, availableWidth > 0 ? Math.floor(availableWidth / 24) : 14); + + const gridStyle = { + gridTemplateColumns: `${DAY_LABEL_WIDTH}px repeat(24, 1fr)`, + gridTemplateRows: `repeat(7, ${CELL_SIZE}px)`, + columnGap: `${CELL_GAP}px`, + rowGap: `${CELL_GAP}px`, + } as const; + + return ( +
+ +
+
+
+

+ Most Productive Hours +

+
+ + ? + +
+ Shows your average commits per hour and day of week over the + selected date range, adjusted to your local timezone. +
+
+
+

+ {customLabel + ? customLabel + : `Last ${selectedDays} days — commit activity by hour & day`} +

+
+ + {/* Controls */} +
+ {/* Preset range pills */} +
+ {PRESET_RANGES.map((r) => ( + + ))} +
+ +
+ + + {showPopover && ( +
+

+ Custom range +

+
+ + + {customError && ( +

{customError}

+ )} + +
+
+ )} +
+ + + +
+ +
+ Less +
+ {[0, maxAvg * 0.25, maxAvg * 0.5, maxAvg * 0.75, maxAvg].map( + (v, i) => ( + + ) + )} +
+ More +
+
+ + {loading ? ( +
+ ) : error ? ( +
+

+ {error} Please try refreshing. +

+
+ ) : ( + <> +
+
+ + {/* Hour axis labels — every 3 hours to avoid crowding */} +
+
{/* spacer for day-label column */} + {HOURS.map((h) => ( +
+ {h % 3 === 0 ? ( + + {formatHour(h)} + + ) : null} +
+ ))} +
+ + {/* 7 × 24 grid */} +
+ {/* Day-of-week row labels (col 1) */} + {DAY_LABELS.map((label, rowIndex) => ( +
+ {rowIndex % 2 === 0 ? label : ""} +
+ ))} + + {/* Data cells */} + {DAY_LABELS.map((_, rowIndex) => + HOURS.map((hour) => { + const cell = cellMap.get(`${rowIndex}-${hour}`); + const avg = cell?.avg ?? 0; + + const showTooltipBelow = rowIndex < 2; + const isNearRightEdge = hour >= 21; + + const tooltipText = `${DAY_FULL[rowIndex]} ${formatHour(hour)} — avg ${avg.toFixed(1)} commit${avg === 1 ? "" : "s"}`; + + return ( + + ); + }) + )} +
+
+
+ +
+

+ {totalCommits} commits analysed. + {peak && peak.avg > 0 && ( + + 🔥 Peak: {DAY_FULL[peak.day]} {formatHour(peak.hour)} — avg{" "} + {peak.avg.toFixed(1)} commits + + )} +

+ {lastUpdated && ( +

+ {minutesAgo === 0 + ? "Updated just now" + : `Updated ${minutesAgo} min ago`} +

+ )} +
+ + )} +
+ ); +} \ No newline at end of file diff --git a/src/lib/metrics-cache.ts b/src/lib/metrics-cache.ts index 3801da6d..d9a75317 100644 --- a/src/lib/metrics-cache.ts +++ b/src/lib/metrics-cache.ts @@ -3,6 +3,7 @@ import type { NextRequest } from "next/server"; export const METRICS_CACHE_TTL_SECONDS = { contributions: 5 * 60, + "productive-hours": 5 * 60, discussions: 10 * 60, repos: 10 * 60, "inactive-repos": 10 * 60,