From 0443c943705ed87331ad295fec382eee88e99ff8 Mon Sep 17 00:00:00 2001 From: hrshjswniii Date: Thu, 2 Jul 2026 00:54:06 +0530 Subject: [PATCH 1/4] [FEAT] : Interactive Activity Heatmap Filters (UX & Interaction --- src/components/ContributionHeatmap.tsx | 168 +++++++++++++++++++++++-- src/components/DailyBreakdownSheet.tsx | 37 +++++- 2 files changed, 189 insertions(+), 16 deletions(-) diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index db8e0b260..3899b975b 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -4,14 +4,44 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; import DailyBreakdownSheet from "@/components/DailyBreakdownSheet"; import { getContributionInsights } from "@/lib/contribution-insights"; -import { Calendar, TrendingUp, Zap, Clock, Award, BarChart2 } from "lucide-react"; +import { Calendar, TrendingUp, Zap, Clock, Award, BarChart2, Filter } from "lucide-react"; +import { useAccount } from "@/components/AccountContext"; interface ContributionHeatmapProps { days?: number; } +interface CommitItem { + sha: string; + message: string; + date: string; + repo: string; + url: string; +} + interface ContributionResponse { + days: number; + total: number; data: Record; + commits: CommitItem[]; +} + +interface RepoLanguage { + name: string; + bytes: number; + percentage: number; +} + +interface RepoSummary { + name: string; + commits: number; + description: string | null; + url: string; + languages?: RepoLanguage[]; +} + +interface ReposApiResponse { + repos: RepoSummary[]; } interface HeatmapCell { @@ -97,7 +127,13 @@ function buildHeatmap(days: number, contributions: Record, fromD export default function ContributionHeatmap({ days = DEFAULT_DAYS, }: ContributionHeatmapProps) { + const { selectedAccount } = useAccount(); const [data, setData] = useState>({}); + const [commits, setCommits] = useState([]); + const [reposData, setReposData] = useState([]); + const [selectedRepo, setSelectedRepo] = useState("all"); + const [selectedLanguage, setSelectedLanguage] = useState("all"); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); @@ -214,21 +250,36 @@ export default function ContributionHeatmap({ const params = new URLSearchParams(); params.set("from", currentFrom); params.set("to", currentTo); + if (selectedAccount) { + params.set("accountId", selectedAccount); + } - fetch(`/api/metrics/contributions?${params.toString()}`) - .then((response) => { + // Reset filters when range/account changes + setSelectedRepo("all"); + setSelectedLanguage("all"); + + Promise.all([ + fetch(`/api/metrics/contributions?${params.toString()}`).then((response) => { + if (!response.ok) throw new Error("API error"); + return response.json() as Promise; + }), + fetch(`/api/metrics/repos?days=${selectedDays}${selectedAccount ? `&accountId=${encodeURIComponent(selectedAccount)}` : ""}`).then((response) => { if (!response.ok) throw new Error("API error"); - return response.json(); + return response.json() as Promise; }) - .then((result: ContributionResponse) => { + ]) + .then(([contribResult, reposResult]) => { if (!active) return; - setData(result.data ?? {}); + setData(contribResult.data ?? {}); + setCommits(contribResult.commits ?? []); + setReposData(reposResult.repos ?? []); setLastUpdated(new Date()); setMinutesAgo(0); }) - .catch(() => { + .catch((err) => { if (!active) return; setError("Failed to load contribution heatmap."); + console.error("Error loading heatmap data:", err); }) .finally(() => { if (!active) return; @@ -238,7 +289,7 @@ export default function ContributionHeatmap({ return () => { active = false; }; - }, [currentFrom, currentTo]); + }, [currentFrom, currentTo, selectedDays, selectedAccount]); useEffect(() => { if (!lastUpdated) return; @@ -259,15 +310,71 @@ export default function ContributionHeatmap({ } return selectedDays; }, [customLabel, customFrom, customTo, selectedDays]); + + // Extract unique repositories + const uniqueRepos = useMemo(() => { + const reposSet = new Set(); + commits.forEach((c) => { + if (c.repo) reposSet.add(c.repo); + }); + return Array.from(reposSet).sort(); + }, [commits]); + + // Extract unique languages + const uniqueLanguages = useMemo(() => { + const langsSet = new Set(); + reposData.forEach((repo) => { + repo.languages?.forEach((l) => { + if (l.name) langsSet.add(l.name); + }); + }); + return Array.from(langsSet).sort(); + }, [reposData]); + + // Map each repo to its languages for quick lookup + const repoLanguagesMap = useMemo(() => { + const map = new Map(); + reposData.forEach((repo) => { + const langs = repo.languages?.map((l) => l.name) ?? []; + map.set(repo.name, langs); + }); + return map; + }, [reposData]); + + // Compute filtered dataset + const filteredData = useMemo(() => { + if (selectedRepo === "all" && selectedLanguage === "all") { + return data; + } + + const counts: Record = {}; + commits.forEach((commit) => { + // 1. Filter by repo + if (selectedRepo !== "all" && commit.repo !== selectedRepo) { + return; + } + // 2. Filter by language + if (selectedLanguage !== "all") { + const repoLangs = repoLanguagesMap.get(commit.repo) ?? []; + if (!repoLangs.includes(selectedLanguage)) { + return; + } + } + + counts[commit.date] = (counts[commit.date] ?? 0) + 1; + }); + + return counts; + }, [data, commits, repoLanguagesMap, selectedRepo, selectedLanguage]); const cells = useMemo( () => buildHeatmap( displayDays, - data, + filteredData, customLabel ? customFrom : undefined, customLabel ? customTo : undefined ), - [displayDays, data, customLabel, customFrom, customTo] + [displayDays, filteredData, customLabel, customFrom, customTo] ); const weekCount = Math.ceil(cells.length / 7); const maxCommits = Math.max( @@ -458,6 +565,42 @@ export default function ContributionHeatmap({ > Colour-blind + + {/* Repository and Language Filters */} +
+
+ + Filter: +
+ + + + +
{/* Legend - Less / More */} @@ -740,7 +883,10 @@ export default function ContributionHeatmap({ ); diff --git a/src/components/DailyBreakdownSheet.tsx b/src/components/DailyBreakdownSheet.tsx index 3c501ff08..b62afa673 100644 --- a/src/components/DailyBreakdownSheet.tsx +++ b/src/components/DailyBreakdownSheet.tsx @@ -1,11 +1,14 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; interface DailyBreakdownSheetProps { date: string | null; onClose: () => void; heatmapData?: Record; + selectedRepo?: string; + selectedLanguage?: string; + reposData?: any[]; } interface RepoCommit { @@ -18,8 +21,11 @@ export default function DailyBreakdownSheet({ date, onClose, heatmapData, + selectedRepo = "all", + selectedLanguage = "all", + reposData = [], }: DailyBreakdownSheetProps) { - const [commits, setCommits] = useState([]); + const [rawCommits, setRawCommits] = useState([]); const [loading, setLoading] = useState(false); const isOpen = date !== null; @@ -27,7 +33,7 @@ export default function DailyBreakdownSheet({ if (!date) return; const totalForDay = heatmapData?.[date] ?? 0; if (totalForDay === 0) { - setCommits([]); + setRawCommits([]); setLoading(false); return; } @@ -35,12 +41,33 @@ export default function DailyBreakdownSheet({ fetch(`/api/metrics/contributions/daily?date=${date}`) .then((res) => res.json()) .then((result) => { - setCommits(result.repos ?? []); + setRawCommits(result.repos ?? []); }) - .catch(() => setCommits([])) + .catch(() => setRawCommits([])) .finally(() => setLoading(false)); }, [date, heatmapData]); + const commits = useMemo(() => { + const repoLangsMap = new Map(); + reposData.forEach(repo => { + const langs = repo.languages?.map((l: any) => l.name) ?? []; + repoLangsMap.set(repo.name, langs); + }); + + return rawCommits.filter(item => { + if (selectedRepo !== "all" && item.repo !== selectedRepo) { + return false; + } + if (selectedLanguage !== "all") { + const repoLangs = repoLangsMap.get(item.repo) ?? []; + if (!repoLangs.includes(selectedLanguage)) { + return false; + } + } + return true; + }); + }, [rawCommits, selectedRepo, selectedLanguage, reposData]); + const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; From f66fdaf6e694016f79814b4d95b08553b3fcb3e2 Mon Sep 17 00:00:00 2001 From: hrshjswniii Date: Thu, 2 Jul 2026 00:59:21 +0530 Subject: [PATCH 2/4] [FEAT] : AI-Driven Goal Mentor (AI Feature) --- src/app/api/goals/mentor-suggestions/route.ts | 279 ++++++++++++++++++ src/components/GoalTracker.tsx | 174 +++++++++++ src/lib/ai-prompts.ts | 43 +++ supabase/schema.sql | 2 +- 4 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 src/app/api/goals/mentor-suggestions/route.ts diff --git a/src/app/api/goals/mentor-suggestions/route.ts b/src/app/api/goals/mentor-suggestions/route.ts new file mode 100644 index 000000000..443f8854f --- /dev/null +++ b/src/app/api/goals/mentor-suggestions/route.ts @@ -0,0 +1,279 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { goalMentorPrompt, GoalMentorPromptParams } from "@/lib/ai-prompts"; +import { GoogleGenerativeAI } from "@google/generative-ai"; +import { + upstashRateLimitFixedWindow, + getUpstashConfig, +} from "@/lib/upstash-rest"; +import { createMemoryFixedWindowRateLimiter } from "@/lib/rate-limit"; + +const MENTOR_INSIGHT_TYPE = "goal_suggestions"; +const MENTOR_LIMIT = 5; +const MENTOR_WINDOW_SECONDS = 60 * 60; // 1 hour +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +const memoryLimiter = createMemoryFixedWindowRateLimiter({ + windowMs: MENTOR_WINDOW_SECONDS * 1000, + pruneIntervalMs: MENTOR_WINDOW_SECONDS * 1000, + maxEntries: 10_000, +}); + +export const dynamic = "force-dynamic"; + +interface GoalSuggestion { + title: string; + target: number; + unit: string; + recurrence: string; + reasoning: string; +} + +interface MentorSuggestionsResponse { + suggestions: GoalSuggestion[]; +} + +function parseJsonSuggestions(raw: string): MentorSuggestionsResponse | null { + try { + const cleaned = raw.replace(/```json|```/g, "").trim(); + const parsed = JSON.parse(cleaned); + if (!Array.isArray(parsed.suggestions)) return null; + + const validated = parsed.suggestions.map((s: any) => ({ + title: String(s.title || "").slice(0, 100), + target: Number(s.target || 1), + unit: String(s.unit || "commits"), + recurrence: String(s.recurrence || "weekly"), + reasoning: String(s.reasoning || ""), + })); + + return { suggestions: validated }; + } catch { + return null; + } +} + +function getStaticFallbackSuggestions(topRepo: string, languages: string[]): MentorSuggestionsResponse { + const primaryLang = languages[0] || "coding"; + return { + suggestions: [ + { + title: `Make 10 commits in ${topRepo}`, + target: 10, + unit: "commits", + recurrence: "weekly", + reasoning: "A steady target of 10 commits this week will help maintain development momentum." + }, + { + title: `Work for 5 hours on ${primaryLang} tasks`, + target: 5, + unit: "hours", + recurrence: "weekly", + reasoning: "Spending focused hours writing code builds mastery in your core programming language." + }, + { + title: `Merge 2 Pull Requests`, + target: 2, + unit: "prs", + recurrence: "weekly", + reasoning: "Setting a goal to merge PRs encourages you to wrap up features and keep branches clean." + } + ] + }; +} + +export async function GET(request: Request) { + const session = await getServerSession(authOptions); + + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const userId = user.id; + const { searchParams } = new URL(request.url); + const forceRefresh = searchParams.get("refresh") === "true"; + + // 1. Check cache first + if (!forceRefresh) { + const { data: cached } = await supabaseAdmin + .from("ai_insights") + .select("*") + .eq("user_id", userId) + .eq("insight_type", MENTOR_INSIGHT_TYPE) + .gte("expires_at", new Date().toISOString()) + .order("generated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (cached) { + return NextResponse.json({ data: cached.content, cached: true }); + } + } + + // 2. Enforce rate limiting + let rateLimitDenied = false; + let retryAfterSeconds = MENTOR_WINDOW_SECONDS; + + if (getUpstashConfig()) { + const result = await upstashRateLimitFixedWindow({ + key: `mentor:${userId}`, + limit: MENTOR_LIMIT, + windowSeconds: MENTOR_WINDOW_SECONDS, + }); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = result.retryAfter ?? MENTOR_WINDOW_SECONDS; + } + } else { + const result = memoryLimiter.check(`mentor:${userId}`, MENTOR_LIMIT); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = Math.max(result.reset - Math.floor(Date.now() / 1000), 1); + } + } + + if (rateLimitDenied) { + return NextResponse.json( + { error: "Rate limit exceeded. Try again later." }, + { + status: 429, + headers: { "Retry-After": String(retryAfterSeconds) }, + } + ); + } + + // 3. Fetch recent metrics to supply context to the LLM + const baseUrl = process.env.NEXTAUTH_URL ?? "http://localhost:3000"; + const cookie = request.headers.get("cookie") ?? ""; + const headers = { Cookie: cookie }; + + let contributionsRaw: any = {}; + let prsRaw: any = {}; + let streakRaw: any = {}; + let reposRaw: any = {}; + let languagesRaw: any = {}; + + try { + const [contributionsRes, prsRes, streakRes, reposRes, languagesRes] = await Promise.all([ + fetch(`${baseUrl}/api/metrics/contributions?days=30`, { headers, cache: "no-store" }), + fetch(`${baseUrl}/api/metrics/prs`, { headers, cache: "no-store" }), + fetch(`${baseUrl}/api/metrics/streak`, { headers, cache: "no-store" }), + fetch(`${baseUrl}/api/metrics/repos?days=30`, { headers, cache: "no-store" }), + fetch(`${baseUrl}/api/metrics/languages?days=30`, { headers, cache: "no-store" }), + ]); + + contributionsRaw = contributionsRes.ok ? await contributionsRes.json() : {}; + prsRaw = prsRes.ok ? await prsRes.json() : {}; + streakRaw = streakRes.ok ? await streakRes.json() : {}; + reposRaw = reposRes.ok ? await reposRes.json() : {}; + languagesRaw = languagesRes.ok ? await languagesRes.json() : {}; + } catch (err) { + console.error("Failed to fetch metrics for AI Mentor:", err); + } + + const commitsByDay: Record = contributionsRaw.data ?? {}; + const totalCommits = Object.values(commitsByDay).reduce((s, c) => s + c, 0); + const activeDays = streakRaw.totalActiveDays ?? 0; + const longestStreak = streakRaw.longest ?? 0; + const prsMerged = prsRaw.merged ?? 0; + const topRepoName = reposRaw.repos?.[0]?.name ?? "your repositories"; + const repoCount = reposRaw.repos?.length ?? 0; + const languagesList = Array.isArray(languagesRaw.languages) + ? languagesRaw.languages.map((l: any) => l.name) + : []; + + const promptParams: GoalMentorPromptParams = { + totalCommits, + activeDays, + longestStreak, + prsMerged, + topRepoName, + repoCount, + languages: languagesList.length > 0 ? languagesList : ["coding"], + }; + + let report: MentorSuggestionsResponse = getStaticFallbackSuggestions(topRepoName, languagesList); + let resolvedSource = "fallback"; + + // 4. Try Gemini first + if (process.env.GEMINI_API_KEY) { + try { + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); + const promptText = goalMentorPrompt(promptParams); + const result = await model.generateContent(promptText); + const text = result.response.text(); + const parsed = parseJsonSuggestions(text); + if (parsed) { + report = parsed; + resolvedSource = "gemini"; + } + } catch (err) { + console.error("Gemini API error in Goal Mentor:", err); + } + } + + // 5. Fallback to Groq if Gemini failed or is not set + if (resolvedSource === "fallback" && process.env.GROQ_API_KEY) { + try { + const promptText = goalMentorPrompt(promptParams); + const groqRes = await fetch( + "https://api.groq.com/openai/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.GROQ_API_KEY}`, + }, + body: JSON.stringify({ + model: "llama-3.3-70b-versatile", + max_tokens: 400, + temperature: 0.7, + messages: [{ role: "user", content: promptText }], + }), + } + ); + + if (groqRes.ok) { + const groqData = await groqRes.json(); + const text = groqData.choices?.[0]?.message?.content || ""; + const parsed = parseJsonSuggestions(text); + if (parsed) { + report = parsed; + resolvedSource = "groq"; + } + } + } catch (err) { + console.error("Groq API error in Goal Mentor:", err); + } + } + + // 6. Cache the result in DB + try { + const now = new Date(); + const expiresAt = new Date(now.getTime() + CACHE_TTL_MS); + + await supabaseAdmin.from("ai_insights").upsert( + { + user_id: userId, + insight_type: MENTOR_INSIGHT_TYPE, + content: report, + generated_at: now.toISOString(), + expires_at: expiresAt.toISOString(), + }, + { onConflict: "user_id,insight_type" } + ); + } catch (err) { + console.error("Failed to cache goal suggestions:", err); + } + + return NextResponse.json({ data: report, cached: false, source: resolvedSource }); +} diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 41c6b0fa4..c3eda81ca 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -345,6 +345,50 @@ export default function GoalTracker() { getCompletionLabel, } = useGoalTracker(); + interface GoalSuggestion { + title: string; + target: number; + unit: string; + recurrence: Recurrence; + reasoning: string; + } + + const [aiSuggestions, setAiSuggestions] = useState([]); + const [suggestionsLoading, setSuggestionsLoading] = useState(false); + const [suggestionsError, setSuggestionsError] = useState(null); + const [suggestionsSource, setSuggestionsSource] = useState(null); + + const fetchSuggestions = useCallback(async (refresh = false) => { + setSuggestionsLoading(true); + setSuggestionsError(null); + try { + const res = await fetch(`/api/goals/mentor-suggestions${refresh ? "?refresh=true" : ""}`); + if (!res.ok) { + if (res.status === 429) { + setSuggestionsError("Rate limit exceeded. Try again in an hour."); + } else { + setSuggestionsError("Failed to fetch suggestions from AI mentor."); + } + return; + } + const json = await res.json(); + if (json.data?.suggestions) { + setAiSuggestions(json.data.suggestions); + setSuggestionsSource(json.source || (json.cached ? "cached" : null)); + } else { + setSuggestionsError("No suggestions received."); + } + } catch (err) { + setSuggestionsError("Failed to fetch suggestions."); + } finally { + setSuggestionsLoading(false); + } + }, []); + + useEffect(() => { + fetchSuggestions(); + }, [fetchSuggestions]); + const { setSummary, setIsUpdating } = useDashboardWidgetA11y("goal-tracker"); useEffect(() => { @@ -756,6 +800,136 @@ export default function GoalTracker() {

)} + {/* ── AI Goal Mentor Suggestions ── */} +
+
+
+ + 🎯 AI Goal Mentor + + {suggestionsSource && ( + + {suggestionsSource === "cached" ? "cached" : suggestionsSource} + + )} +
+ +
+ + {suggestionsLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : suggestionsError ? ( +
+

+ {suggestionsError} +

+ +
+ ) : aiSuggestions.length > 0 ? ( +
+ {aiSuggestions.map((suggestion, idx) => { + const unitIcon = + suggestion.unit === "commits" ? "⚡" : + suggestion.unit === "prs" ? "🔀" : + suggestion.unit === "hours" ? "⏱️" : + suggestion.unit === "streak" ? "🔥" : "📝"; + return ( +
{ + setTitle(suggestion.title); + setTarget(suggestion.target); + setUnit(suggestion.unit); + setRecurrence(suggestion.recurrence); + // Scroll to create goal form and highlight + const formElement = document.getElementById("create-goal-form"); + if (formElement) { + formElement.scrollIntoView({ behavior: "smooth" }); + formElement.classList.add("ring-2", "ring-[var(--accent)]"); + setTimeout(() => { + formElement.classList.remove("ring-2", "ring-[var(--accent)]"); + }, 2000); + } + }} + > +
+
+ + {unitIcon} {suggestion.unit} + + + {suggestion.recurrence} + +
+

+ {suggestion.title} ({suggestion.target} {suggestion.unit}) +

+

+ "{suggestion.reasoning}" +

+
+
+ + Click to use suggestion + + + + +
+
+ ); + })} +
+ ) : ( +

+ No suggestions available. +

+ )} +
+ {/* Goal Creation Form */}
Date: Thu, 2 Jul 2026 01:07:33 +0530 Subject: [PATCH 3/4] [FEAT] : . Outbound Webhooks Dashboard & Retry History (Advanced Integrations) --- src/app/api/webhooks/custom/[id]/route.ts | 2 +- src/components/webhook/WebhookManager.tsx | 185 +++++++++++++++++++--- 2 files changed, 163 insertions(+), 24 deletions(-) diff --git a/src/app/api/webhooks/custom/[id]/route.ts b/src/app/api/webhooks/custom/[id]/route.ts index 83b04d54e..afb9a85fe 100644 --- a/src/app/api/webhooks/custom/[id]/route.ts +++ b/src/app/api/webhooks/custom/[id]/route.ts @@ -52,7 +52,7 @@ export async function GET( const { data: recentDeliveries } = await supabaseAdmin .from("webhook_deliveries") - .select("id, event, status_code, success, error_message, delivered_at") + .select("id, event, status_code, success, error_message, delivered_at, payload") .eq("webhook_id", id) .order("delivered_at", { ascending: false }) .limit(20); diff --git a/src/components/webhook/WebhookManager.tsx b/src/components/webhook/WebhookManager.tsx index 1e5bf7278..cd07d1cfc 100644 --- a/src/components/webhook/WebhookManager.tsx +++ b/src/components/webhook/WebhookManager.tsx @@ -19,6 +19,7 @@ interface WebhookDelivery { success: boolean; error_message: string | null; delivered_at: string; + payload?: Record; } const AVAILABLE_EVENTS = [ @@ -55,6 +56,52 @@ export default function WebhookManager() { message: string; } | null>(null); + const [expandedDeliveryId, setExpandedDeliveryId] = useState(null); + const [retryingId, setRetryingId] = useState(null); + const [retryResult, setRetryResult] = useState<{ + id: string; + success: boolean; + message: string; + } | null>(null); + + async function handleRetry(webhookId: string, deliveryId: string) { + setRetryingId(deliveryId); + setRetryResult(null); + + try { + const res = await fetch( + `/api/webhooks/custom/${webhookId}/deliveries/${deliveryId}/retry`, + { method: "POST" } + ); + const data = await res.json(); + setRetryResult({ + id: deliveryId, + success: data.success, + message: data.success + ? "Re-delivery successful" + : data.error || `Failed (HTTP ${data.statusCode})`, + }); + + // Refresh deliveries history + const detailsRes = await fetch(`/api/webhooks/custom/${webhookId}`); + if (detailsRes.ok) { + const detailsData = await detailsRes.json(); + setWebhookDetails({ + config: detailsData.webhook, + deliveries: detailsData.recentDeliveries || [], + }); + } + } catch { + setRetryResult({ + id: deliveryId, + success: false, + message: "Failed to retry delivery", + }); + } finally { + setRetryingId(null); + } + } + const loadWebhooks = useCallback(async () => { try { const res = await fetch("/api/webhooks/custom"); @@ -150,12 +197,17 @@ export default function WebhookManager() { setSelectedWebhook(id); setDetailsLoading(true); setTestResult(null); + setExpandedDeliveryId(null); + setRetryResult(null); try { const res = await fetch(`/api/webhooks/custom/${id}`); if (res.ok) { const data = await res.json(); - setWebhookDetails(data); + setWebhookDetails({ + config: data.webhook, + deliveries: data.recentDeliveries || [], + }); } } catch { setWebhookDetails(null); @@ -511,28 +563,115 @@ export default function WebhookManager() {

Recent Deliveries

-
- {webhookDetails.deliveries.map((delivery) => ( -
- - - {getEventLabel(delivery.event)} - - - {delivery.status_code || "Error"} - - - {formatDate(delivery.delivered_at)} - -
- ))} +
+ {webhookDetails.deliveries.map((delivery) => { + const isExpanded = expandedDeliveryId === delivery.id; + return ( +
setExpandedDeliveryId(isExpanded ? null : delivery.id)} + className="group flex flex-col rounded-lg border border-[var(--border)] bg-[var(--control)] hover:border-[var(--accent)]/50 transition-colors cursor-pointer overflow-hidden" + > + {/* Summary Row */} +
+ + + {getEventLabel(delivery.event)} + + + {delivery.status_code || "Fail"} + + + {formatDate(delivery.delivered_at)} + + + + +
+ + {/* Expandable Details Container */} + {isExpanded && ( +
e.stopPropagation()} // Prevent collapse when clicking inside details + > + {delivery.error_message && ( +
+ + Delivery Error + +
+ {delivery.error_message} +
+
+ )} + + {delivery.payload && ( +
+ + Request Payload + +
+                                {JSON.stringify(delivery.payload, null, 2)}
+                              
+
+ )} + +
+ + + {retryResult && retryResult.id === delivery.id && ( + + {retryResult.success ? "✓ " : "✗ "} + {retryResult.message} + + )} +
+
+ )} +
+ ); + })}
) : ( From fb58c63d58345a0f50fdef95d78a6726692ad219 Mon Sep 17 00:00:00 2001 From: hrshjswniii Date: Thu, 2 Jul 2026 01:12:46 +0530 Subject: [PATCH 4/4] [FEAT] : add Timezone-Aware Weekly Digest (Aesthetics & UX) --- src/app/api/cron/weekly-digest/route.ts | 72 +++++++++++++++++++++---- vercel.json | 2 +- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/app/api/cron/weekly-digest/route.ts b/src/app/api/cron/weekly-digest/route.ts index 9e00a4ad5..5961bcf19 100644 --- a/src/app/api/cron/weekly-digest/route.ts +++ b/src/app/api/cron/weekly-digest/route.ts @@ -70,12 +70,51 @@ type SendResult = { // ─── Helpers ────────────────────────────────────────────────────────────────── -function currentWeekLabel(): string { - const now = new Date(); - const dayOfWeek = now.getDay(); +function isUserLocalTimeTargetRange(timezone: string | null | undefined): boolean { + if (!timezone) { + // Fallback: If no timezone is set, treat as UTC. + const nowUtc = new Date(); + const utcHour = nowUtc.getUTCHours(); + const utcDay = nowUtc.getUTCDay(); // 1 = Monday + return utcDay === 1 && utcHour === 9; + } + + try { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour12: false, + weekday: "short", + hour: "numeric", + }); + const parts = formatter.formatToParts(new Date()); + const partsMap = Object.fromEntries(parts.map((p) => [p.type, p.value])); + const localWeekday = partsMap.weekday; // "Mon", "Tue", etc. + const localHour = parseInt(partsMap.hour, 10); + return localWeekday === "Mon" && localHour === 9; + } catch (err) { + // Fallback on invalid timezone + const nowUtc = new Date(); + const utcHour = nowUtc.getUTCHours(); + const utcDay = nowUtc.getUTCDay(); + return utcDay === 1 && utcHour === 9; + } +} + +function currentWeekLabelInTimezone(timezone: string | null | undefined): string { + let localDate = new Date(); + if (timezone) { + try { + const options = { timeZone: timezone, year: "numeric", month: "numeric", day: "numeric" } as const; + const formatter = new Intl.DateTimeFormat("en-US", options); + localDate = new Date(formatter.format(new Date())); + } catch (_) {} + } + + const dayOfWeek = localDate.getDay(); const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; - const monday = new Date(now); - monday.setDate(now.getDate() + daysToMonday); + const monday = new Date(localDate); + monday.setDate(localDate.getDate() + daysToMonday); + return monday.toLocaleDateString("en-GB", { day: "numeric", month: "long", @@ -148,17 +187,21 @@ async function recordDigestSent(userId: string): Promise { } /** - * Process a single user: check cooldown, fetch metrics, render and send email, + * Process a single user: check timezone, check cooldown, fetch metrics, render and send email, * then record the send timestamp. */ async function processUser( user: UserRow, - weekLabel: string, githubToken: string | undefined ): Promise<{ - status: "sent" | "failed" | "skipped_cooldown" | "skipped_unconfigured"; + status: "sent" | "failed" | "skipped_cooldown" | "skipped_unconfigured" | "skipped_timezone"; error?: string; }> { + // ── Timezone check ──────────────────────────────────────────────────────── + if (!isUserLocalTimeTargetRange(user.timezone)) { + return { status: "skipped_timezone" }; + } + // ── Cooldown guard ──────────────────────────────────────────────────────── if (user.last_digest_sent_at) { const lastSent = new Date(user.last_digest_sent_at).getTime(); @@ -167,6 +210,9 @@ async function processUser( } } + // ── Dynamic Week Label ─────────────────────────────────────────────────── + const weekLabel = currentWeekLabelInTimezone(user.timezone); + // ── Metric aggregation ──────────────────────────────────────────────────── let metrics: DigestMetrics | null = null; if (githubToken) { @@ -238,13 +284,13 @@ export async function GET(request: Request) { } try { - const weekLabel = currentWeekLabel(); const githubToken = process.env.GITHUB_TOKEN || undefined; let totalUsersProcessed = 0; let emailsSent = 0; let emailsFailed = 0; let skippedCount = 0; + let skippedTimezoneCount = 0; const errors: SendError[] = []; // 2. Page through opted-in users so no single query loads the full table. @@ -257,6 +303,7 @@ export async function GET(request: Request) { .select("id, github_login, email, timezone, last_digest_sent_at") .eq("weekly_digest_opt_in", true) .not("email", "is", null) + .order("id") .range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1); if (error) { @@ -276,7 +323,7 @@ export async function GET(request: Request) { const batch = (users as UserRow[]).slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( - batch.map((user) => processUser(user, weekLabel, githubToken)) + batch.map((user) => processUser(user, githubToken)) ); for (let j = 0; j < results.length; j++) { @@ -304,6 +351,8 @@ export async function GET(request: Request) { }); } else if (status === "skipped_cooldown") { skippedCount++; + } else if (status === "skipped_timezone") { + skippedTimezoneCount++; } } } @@ -319,7 +368,7 @@ export async function GET(request: Request) { } console.log( - `[weekly-digest] done — processed:${totalUsersProcessed} sent:${emailsSent} failed:${emailsFailed} skipped:${skippedCount}` + `[weekly-digest] done — processed:${totalUsersProcessed} sent:${emailsSent} failed:${emailsFailed} skipped_cooldown:${skippedCount} skipped_timezone:${skippedTimezoneCount}` ); return NextResponse.json({ @@ -328,6 +377,7 @@ export async function GET(request: Request) { emailsSent, emailsFailed, skippedCount, + skippedTimezoneCount, errors, }); } catch (err) { diff --git a/vercel.json b/vercel.json index c4eb5d92b..73bd2769a 100644 --- a/vercel.json +++ b/vercel.json @@ -2,7 +2,7 @@ "crons": [ { "path": "/api/cron/weekly-digest", - "schedule": "0 9 * * 1" + "schedule": "0 * * * *" }, { "path": "/api/wakatime/sync",