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/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/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/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;
diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx
index 387821320..04a0ebe71 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(() => {
@@ -759,6 +803,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 */}