Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/Services/System/ScheduledTaskService.cs
Original file line number Diff line number Diff line change
@@ -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) { }
}
}
55 changes: 54 additions & 1 deletion src/app/api/metrics/weekly-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -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));

Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const CodingActivityInsightsCard = dynamic(
{ loading: () => <SkeletonCard /> },
);

const WeeklyCodingInsightsCard = dynamic(
() => import("@/components/WeeklyCodingInsightsCard"),
{ loading: () => <SkeletonCard /> },
);

const ActivityRingChart = dynamic(
() => import("@/components/ActivityRingChart"),
{ loading: () => <SkeletonCard /> },
Expand Down Expand Up @@ -171,6 +176,13 @@ export default async function DashboardPage() {
<TodayFocusHero userName={session.user?.name ?? null} />
</section>

{/* Weekly Coding Insights */}
<section className="mb-10" data-export-id="weekly-coding-insights">
<LazyWidget fallback={<SkeletonCard />}>
<WeeklyCodingInsightsCard />
</LazyWidget>
</section>

{/* Featured Section */}
<section className="mt-10 mb-12">
<div className="relative overflow-hidden rounded-xl border border-[var(--border)] bg-gradient-to-r from-violet-950/20 via-indigo-950/10 to-transparent p-8 shadow-lg hover:shadow-xl transition-shadow flex flex-col md:flex-row justify-between items-start md:items-center gap-8">
Expand Down
208 changes: 208 additions & 0 deletions src/components/WeeklyCodingInsightsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"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 (
<div className="rounded-lg bg-[var(--control)] p-4 flex flex-col gap-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">{label}</span>
</div>
<div className="flex items-end justify-between">
<span className="text-2xl font-bold text-[var(--card-foreground)]">{value}</span>
{trendLabel && (
<span className={`text-xs font-medium ${trendUp ? "text-[var(--success)]" : trendUp === false ? "text-[var(--destructive)]" : "text-[var(--muted-foreground)]"}`}>
{trendLabel}
</span>
)}
</div>
</div>
);
}

function HighlightRow({ icon: Icon, title, value }: { icon: any; title: string; value: string | number }) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] bg-[var(--card-muted)]">
<div className="rounded-full bg-[var(--accent)]/10 p-2 text-[var(--accent)]">
<Icon className="h-4 w-4" />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]">{title}</span>
<span className="text-sm font-semibold text-[var(--card-foreground)]">{value}</span>
</div>
</div>
);
}

export default function WeeklyCodingInsightsCard() {
const { selectedAccount } = useAccount();
const [summary, setSummary] = useState<WeeklySummaryData | null>(null);
const [insights, setInsights] = useState<CodingInsightData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm space-y-4">
<Skeleton className="h-6 w-48 mb-4" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
</div>
<Skeleton className="h-20 w-full rounded-lg" />
</div>
);
}

if (error) {
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--card-foreground)] mb-4">Weekly Coding Insights</h2>
<div className="rounded-lg border border-[var(--destructive)]/20 bg-[var(--destructive)]/10 p-4 text-sm text-[var(--destructive)]">
{error}
<button onClick={fetchData} className="ml-3 underline">Try again</button>
</div>
</div>
);
}

const hasActivity = summary && (summary.commits.current > 0 || summary.prs.thisWeek.opened > 0 || summary.prs.thisWeek.merged > 0 || summary.issues.thisWeek > 0);

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="flex items-center gap-2 mb-6">
<Sparkles className="h-5 w-5 text-[var(--accent)]" />
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">Weekly Coding Insights</h2>
</div>

{!hasActivity ? (
<div className="flex flex-col items-center justify-center py-10 text-center rounded-lg border border-dashed border-[var(--border)] bg-[var(--card-muted)] px-4">
<div className="rounded-full bg-[var(--control)] p-4 mb-4">
<Sparkles className="h-8 w-8 text-[var(--muted-foreground)]" />
</div>
<h3 className="text-base font-semibold text-[var(--card-foreground)]">No activity this week</h3>
<p className="mt-1 text-sm text-[var(--muted-foreground)] max-w-sm">
It looks like you haven't made any commits, opened PRs, or closed issues in the past 7 days. Time to get coding!
</p>
</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox
icon={GitCommit}
label="Commits"
value={summary!.commits.current}
trendLabel={summary!.commits.trend !== 'same' ? `${summary!.commits.trend === 'up' ? '+' : '-'}${Math.abs(summary!.commits.delta)}` : undefined}
trendUp={summary!.commits.trend === 'up'}
/>
<StatBox
icon={GitPullRequest}
label="PRs Opened"
value={summary!.prs.thisWeek.opened}
/>
<StatBox
icon={GitMerge}
label="PRs Merged"
value={summary!.prs.thisWeek.merged}
/>
<StatBox
icon={CheckCircle}
label="Issues Closed"
value={summary!.issues.thisWeek}
/>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{insights?.mostActiveDay ? (
<HighlightRow
icon={Calendar}
title="Most Productive Day"
value={insights.mostActiveDay.day}
/>
) : (
<HighlightRow
icon={Calendar}
title="Active Days"
value={`${summary!.activeDays.thisWeek} / 7`}
/>
)}

<HighlightRow
icon={Star}
title="Most Active Repo"
value={summary!.topRepo || "None"}
/>

<HighlightRow
icon={Flame}
title="Commit Streak"
value={`${summary!.streak} Days`}
/>
</div>
</div>
)}
</div>
);
}