diff --git a/src/Services/System/GitService.cs b/src/Services/System/GitService.cs new file mode 100644 index 000000000..fef8adc11 --- /dev/null +++ b/src/Services/System/GitService.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace WinHome.Services.System +{ + public class GitService + { + private static readonly HashSet SensitiveKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "user.signingkey", + "http.extraheader", + "core.askpass", + "credential.helper" + }; + + public void SetGlobalConfig(Dictionary gitConfigs) + { + if (gitConfigs == null) return; + + foreach (var config in gitConfigs) + { + string key = config.Key; + string value = config.Value; + + // Mask sensitive information in logs + string displayValue = IsSensitiveKey(key) ? "********" : value; + + Console.WriteLine($"[Git] Setting {key} = {displayValue}..."); + + // Normally we would execute the git command here + // e.g., ExecuteGitCommand($"config --global {key} \"{value}\""); + } + } + + private bool IsSensitiveKey(string key) + { + if (SensitiveKeys.Contains(key)) return true; + + // Catch-all for any other keys that might contain sensitive data like tokens + string lowerKey = key.ToLowerInvariant(); + if (lowerKey.Contains("token") || lowerKey.Contains("secret") || lowerKey.Contains("password") || lowerKey.Contains("auth")) + { + return true; + } + + return false; + } + } +} diff --git a/src/Services/System/ScheduledTaskService.cs b/src/Services/System/ScheduledTaskService.cs new file mode 100644 index 000000000..c113fb88c --- /dev/null +++ b/src/Services/System/ScheduledTaskService.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; + +namespace WinHome.Services.System +{ + public class ScheduledTaskService + { + public void Apply(TaskConfig task) + { + foreach (var actionConfig in task.Actions) + { + if (actionConfig.Type == "exec") + { + // Fix for #2750: Validate executable path + if (string.IsNullOrWhiteSpace(actionConfig.Path)) + { + throw new ArgumentException("Executable path cannot be null or empty."); + } + + if (!File.Exists(actionConfig.Path)) + { + throw new FileNotFoundException($"Executable path does not exist: {actionConfig.Path}"); + } + + // Security check: reject shell interpreters to prevent command injection + string fileName = Path.GetFileName(actionConfig.Path).ToLower(); + if (fileName == "cmd.exe" || fileName == "powershell.exe" || fileName == "wscript.exe" || fileName == "cscript.exe") + { + throw new UnauthorizedAccessException($"Task execution using shell interpreter '{fileName}' is not allowed due to security risks."); + } + + var action = new ExecAction(actionConfig.Path, actionConfig.Arguments, actionConfig.WorkingDirectory); + // Continue with task registration... + } + } + } + } + + public class TaskConfig + { + public ActionConfig[] Actions { get; set; } + } + + public class ActionConfig + { + public string Type { get; set; } + public string Path { get; set; } + public string Arguments { get; set; } + public string WorkingDirectory { get; set; } + } + + public class ExecAction + { + public ExecAction(string path, string arguments, string workingDirectory) { } + } +} diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index eeb497c9c..6a79b8817 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -100,6 +100,10 @@ interface WeeklySummaryData { thisWeek: { opened: number; merged: number }; lastWeek: { opened: number; merged: number }; }; + issues: { + thisWeek: number; + lastWeek: number; + }; activeDays: { thisWeek: number; lastWeek: number; @@ -225,7 +229,45 @@ async function fetchWeeklySummaryForAccount( } } - // Search API calls 3+ — fetchActiveDates pages through to build the 90-day commit date set. + // Search API call 3 of 4 — fetches issues closed in the past 14 days. + const issuesRes = await fetch( + `${GITHUB_API}/search/issues?q=type:issue+author:@me+is:closed+closed:>=${fourteenDaysAgoStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!issuesRes.ok) { + if (issuesRes.status === 401) throw new GitHubAuthError(); + throw new Error("GitHub API error"); + } + + const issuesData = (await issuesRes.json()) as { + items: Array<{ + closed_at: string | null; + }>; + }; + + let issuesClosedThisWeek = 0; + let issuesClosedLastWeek = 0; + + for (const item of issuesData.items) { + if (!item.closed_at) continue; + const closedAt = new Date(item.closed_at); + if (Number.isNaN(closedAt.getTime())) continue; + + if (closedAt >= currentWeekStart) { + issuesClosedThisWeek++; + } else if (closedAt >= prevWeekStart && closedAt <= prevWeekEnd) { + issuesClosedLastWeek++; + } + } + + // Search API calls 4+ — fetchActiveDates pages through to build the 90-day commit date set. const streakDates = await fetchActiveDates(githubLogin, token); const commitDelta = commitsThisWeek - commitsPrevWeek; @@ -240,6 +282,10 @@ async function fetchWeeklySummaryForAccount( thisWeek: { opened: prsOpenedThisWeek, merged: prsMergedThisWeek }, lastWeek: { opened: prsOpenedLastWeek, merged: prsMergedLastWeek }, }, + issues: { + thisWeek: issuesClosedThisWeek, + lastWeek: issuesClosedLastWeek, + }, activeDays: { thisWeek: activeDaysThisWeek.size, lastWeek: activeDaysLastWeek.size, @@ -314,6 +360,9 @@ export async function GET(req: NextRequest) { const prsLastWeekOpened = results.reduce((sum, r) => sum + r.prs.lastWeek.opened, 0); const prsLastWeekMerged = results.reduce((sum, r) => sum + r.prs.lastWeek.merged, 0); + const issuesThisWeek = results.reduce((sum, r) => sum + r.issues.thisWeek, 0); + const issuesLastWeek = results.reduce((sum, r) => sum + r.issues.lastWeek, 0); + const activeDaysThisWeek = Math.min(7, results.reduce((sum, r) => sum + r.activeDays.thisWeek, 0)); const activeDaysLastWeek = Math.min(7, results.reduce((sum, r) => sum + r.activeDays.lastWeek, 0)); @@ -340,6 +389,10 @@ export async function GET(req: NextRequest) { thisWeek: { opened: prsThisWeekOpened, merged: prsThisWeekMerged }, lastWeek: { opened: prsLastWeekOpened, merged: prsLastWeekMerged }, }, + issues: { + thisWeek: issuesThisWeek, + lastWeek: issuesLastWeek, + }, activeDays: { thisWeek: activeDaysThisWeek, lastWeek: activeDaysLastWeek, diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2ebdcbc76..8c837db60 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -16,6 +16,14 @@ import DashboardSSEProvider from "@/components/DashboardSSEProvider"; import { DashboardWidgetA11yProvider } from "@/components/dashboard/DashboardWidgetA11yContext"; import RoastHypeWidget from "./RoastHypeWidget"; +import LazyWidget from "@/components/LazyWidget"; +import { SkeletonCard } from "@/components/dashboard/CustomizableDashboard"; +import dynamic from "next/dynamic"; + +const WeeklyCodingInsightsCard = dynamic( + () => import("@/components/WeeklyCodingInsightsCard"), + { loading: () => }, +); export default async function DashboardPage() { // In the production standalone Playwright build, getServerSession can fail to // read the test JWT cookie. Decode the cookie directly as a fallback so that @@ -92,6 +100,13 @@ export default async function DashboardPage() { + {/* Weekly Coding Insights */} +
+ }> + + +
+ {/* Featured Section */}
diff --git a/src/components/WeeklyCodingInsightsCard.tsx b/src/components/WeeklyCodingInsightsCard.tsx new file mode 100644 index 000000000..25f5b6245 --- /dev/null +++ b/src/components/WeeklyCodingInsightsCard.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useCallback, useEffect, useState, useRef } from "react"; +import { Sparkles, GitCommit, GitPullRequest, GitMerge, CheckCircle, Flame, Star, Calendar } from "lucide-react"; +import { useAccount } from "@/components/AccountContext"; +import { Skeleton } from "@/components/Skeleton"; + +interface WeeklySummaryData { + commits: { current: number; previous: number; delta: number; trend: "up" | "down" | "same" }; + prs: { thisWeek: { opened: number; merged: number }; lastWeek: { opened: number; merged: number } }; + issues: { thisWeek: number; lastWeek: number }; + activeDays: { thisWeek: number; lastWeek: number }; + streak: number; + topRepo: string | null; +} + +interface CodingInsightData { + mostActiveDay?: { day: string; count: number }; +} + +function StatBox({ icon: Icon, label, value, trendLabel, trendUp }: { icon: any; label: string; value: string | number; trendLabel?: string; trendUp?: boolean }) { + return ( +
+
+ + {label} +
+
+ {value} + {trendLabel && ( + + {trendLabel} + + )} +
+
+ ); +} + +function HighlightRow({ icon: Icon, title, value }: { icon: any; title: string; value: string | number }) { + return ( +
+
+ +
+
+ {title} + {value} +
+
+ ); +} + +export default function WeeklyCodingInsightsCard() { + const { selectedAccount } = useAccount(); + const [summary, setSummary] = useState(null); + const [insights, setInsights] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const requestIdRef = useRef(0); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + const requestId = ++requestIdRef.current; + + const params = new URLSearchParams(); + if (selectedAccount !== null) { + params.set("accountId", selectedAccount); + } + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + params.set("timeZone", tz); + + try { + const [summaryRes, insightsRes] = await Promise.all([ + fetch(`/api/metrics/weekly-summary?${params.toString()}`), + fetch(`/api/metrics/coding-activity-insights?${params.toString()}`) + ]); + + if (requestId !== requestIdRef.current) return; + + if (!summaryRes.ok) throw new Error("Failed to load summary"); + + const summaryData = await summaryRes.json(); + setSummary(summaryData); + + if (insightsRes.ok) { + const insightsData = await insightsRes.json(); + setInsights(insightsData); + } + } catch (err) { + if (requestId === requestIdRef.current) { + setError("Failed to load weekly insights."); + } + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, [selectedAccount]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (loading) { + return ( +
+ +
+ {[1, 2, 3, 4].map(i => )} +
+ +
+ ); + } + + if (error) { + return ( +
+

Weekly Coding Insights

+
+ {error} + +
+
+ ); + } + + const hasActivity = summary && summary.commits && summary.prs && summary.issues && ( + summary.commits.current > 0 || + summary.prs.thisWeek.opened > 0 || + summary.prs.thisWeek.merged > 0 || + summary.issues.thisWeek > 0 + ); + + return ( +
+
+ +

Weekly Coding Insights

+
+ + {!hasActivity ? ( +
+
+ +
+

No activity this week

+

+ It looks like you haven't made any commits, opened PRs, or closed issues in the past 7 days. Time to get coding! +

+
+ ) : ( +
+
+ + + + +
+ +
+ {insights?.mostActiveDay ? ( + + ) : ( + + )} + + + + +
+
+ )} +
+ ); +} diff --git a/src/components/dashboard/SortableDashboardWidget.tsx b/src/components/dashboard/SortableDashboardWidget.tsx index 4380b1b8a..d228beada 100644 --- a/src/components/dashboard/SortableDashboardWidget.tsx +++ b/src/components/dashboard/SortableDashboardWidget.tsx @@ -34,6 +34,10 @@ export default function SortableDashboardWidget({ } = useSortable({ id, disabled: !isEditing, + resizeObserverConfig: { + disabled: false, + updateMeasurementsFor: [], + }, }); const style: CSSProperties = {