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
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"navigation": {
"main": "Main navigation",
"overview": "Overview",
"timeline": "Timeline",
"resume": "Resume",
"personality": "Personality",
"leaderboard": "Leaderboard",
Expand Down
1 change: 1 addition & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"navigation": {
"main": "Navegación principal",
"overview": "Resumen",
"timeline": "Línea de tiempo",
"resume": "CV",
"personality": "Personalidad",
"leaderboard": "Clasificación",
Expand Down
6 changes: 6 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export default async function DashboardPage() {
</svg>
Compare Friends
</Link>
<Link
href="/dashboard/timeline"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--accent)] bg-[var(--accent-soft)] px-4 py-2 text-sm font-semibold text-[var(--accent)] transition-opacity hover:opacity-90"
>
🕒 Activity Timeline
</Link>
<Link
href="/dashboard/settings"
className="secondary-button inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium"
Expand Down
46 changes: 46 additions & 0 deletions src/app/dashboard/timeline/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import TimelineContainer from "@/components/dashboard/TimelineContainer";

export const metadata = {
title: "Developer Activity Timeline — DevTrack",
description:
"Interactive chronological log of your GitHub activities including commits, PRs, issues, and reviews across repositories.",
};

export default async function TimelinePage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/");

return (
<div className="min-h-screen bg-[var(--background)] px-4 py-8 text-[var(--foreground)] sm:px-6 lg:px-8 max-w-[1200px] mx-auto">
{/* Page header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 py-1.5 text-sm text-[var(--muted-foreground)] transition hover:text-[var(--card-foreground)] hover:border-[var(--accent)]/40"
aria-label="Back to dashboard"
>
<ArrowLeft size={14} aria-hidden="true" />
Dashboard
</Link>
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">
Developer Activity Timeline
</h1>
<p className="mt-0.5 text-sm text-[var(--muted-foreground)]">
Explore your contribution patterns and track development history in real-time
</p>
</div>
</div>
</div>

{/* Main Container */}
<TimelineContainer />
</div>
);
}
214 changes: 214 additions & 0 deletions src/components/dashboard/TimelineContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"use client";

import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAccount } from "@/components/AccountContext";
import { RefreshCw, Calendar, Eye, Layers } from "lucide-react";
import type { TimelineActivity, DateRangeState } from "@/lib/timeline-formatter";
import {
transformActivityItemToTimeline,
filterActivities,
groupActivities,
} from "@/lib/timeline-formatter";
import type { ActivityItem } from "@/lib/activity-formatter";
import TimelineFilterControls from "./TimelineFilterControls";
import TimelineDateRangeControls from "./TimelineDateRangeControls";
import TimelineViewControls from "./TimelineViewControls";
import TimelineItem from "./TimelineItem";

export default function TimelineContainer() {
const { selectedAccount } = useAccount();
const [rawActivities, setRawActivities] = useState<TimelineActivity[]>([]);
const [loading, setLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);

const offsetRef = useRef(0);
const LIMIT = 20;

// Filter States
const [activeFilter, setActiveFilter] = useState<"all" | "commit" | "pr" | "issue" | "review">("all");
const [dateRange, setDateRange] = useState<DateRangeState>({ preset: "30" });
const [activeView, setActiveView] = useState<"daily" | "weekly" | "monthly">("daily");

const fetchActivities = useCallback((isLoadMore = false) => {
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setLoading(true);
offsetRef.current = 0;
}
setError(null);

const previousOffset = offsetRef.current;
const currentOffset = isLoadMore ? previousOffset + LIMIT : 0;
offsetRef.current = currentOffset;

let queryParams = `?limit=${LIMIT}&offset=${currentOffset}`;
if (selectedAccount !== null) {
queryParams += `&accountId=${encodeURIComponent(selectedAccount)}`;
}

fetch(`/api/metrics/activity${queryParams}`)
.then((res) => {
if (!res.ok) {
throw new Error("Unable to fetch activities");
}
return res.json();
})
.then((payload: { items?: ActivityItem[] }) => {
const fetched = (payload.items ?? []).map(transformActivityItemToTimeline);
if (isLoadMore) {
setRawActivities((prev) => [...prev, ...fetched]);
} else {
setRawActivities(fetched);
}
setHasMore(fetched.length === LIMIT);
})
.catch(() => {
offsetRef.current = previousOffset;
setError("Failed to fetch activity timeline data. Please try again.");
})
.finally(() => {
setLoading(false);
setIsLoadingMore(false);
});
}, [selectedAccount]);

useEffect(() => {
fetchActivities(false);
}, [fetchActivities]);

// Client side filtering based on selections
const filteredActivities = useMemo(() => {
return filterActivities(rawActivities, activeFilter, dateRange);
}, [rawActivities, activeFilter, dateRange]);

// Client side grouping (daily, weekly, monthly)
const groupedActivities = useMemo(() => {
return groupActivities(filteredActivities, activeView);
}, [filteredActivities, activeView]);

return (
<div className="space-y-6">
{/* Filters card */}
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-sm space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Layers size={15} className="text-[var(--accent)]" />
<span className="text-sm font-semibold text-[var(--foreground)]">Activity Type</span>
</div>
<TimelineFilterControls activeFilter={activeFilter} onChange={setActiveFilter} />
</div>

<div className="h-px bg-[var(--border)]" />

<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Calendar size={15} className="text-[var(--accent)]" />
<span className="text-sm font-semibold text-[var(--foreground)]">Time Period</span>
</div>
<TimelineDateRangeControls value={dateRange} onChange={setDateRange} />
</div>

<div className="h-px bg-[var(--border)]" />

<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Eye size={15} className="text-[var(--accent)]" />
<span className="text-sm font-semibold text-[var(--foreground)]">Grouping Interval</span>
</div>
<TimelineViewControls activeView={activeView} onChange={setActiveView} />
</div>
</div>

{/* Main timeline listing */}
{loading && !isLoadingMore ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="h-28 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 animate-pulse">
<div className="h-4 w-40 rounded bg-[var(--border)] mb-2" />
<div className="h-3 w-64 rounded bg-[var(--border)]" />
</div>
))}
</div>
) : error ? (
<div className="rounded-xl border border-[var(--destructive)]/20 bg-[var(--destructive)]/5 p-6 text-center space-y-3">
<p className="text-sm text-[var(--destructive)] font-medium">{error}</p>
<button
type="button"
onClick={() => fetchActivities(false)}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--destructive)]/10 px-4 py-2 text-xs font-semibold text-[var(--destructive)] transition hover:bg-[var(--destructive)]/20"
>
<RefreshCw size={13} />
Try Again
</button>
</div>
) : Object.keys(groupedActivities).length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--border)] bg-[var(--card-muted)] p-12 text-center">
<HelpCircleIcon className="mx-auto h-12 w-12 text-[var(--muted-foreground)] opacity-60" />
<h3 className="mt-4 text-sm font-semibold text-[var(--foreground)]">No matching activities</h3>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
Try adjusting your filters, date ranges, or load more events from GitHub.
</p>
</div>
) : (
<div className="space-y-8">
{Object.entries(groupedActivities).map(([groupTitle, list]) => (
<div key={groupTitle} className="space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-[var(--muted-foreground)] border-b border-[var(--border)] pb-2 flex items-center gap-2">
<span>{groupTitle}</span>
<span className="rounded-full bg-[var(--border)] px-2 py-0.5 text-[10px] font-semibold text-[var(--foreground)]">
{list.length}
</span>
</h3>
<div className="pl-2">
{list.map((act) => (
<TimelineItem key={act.id} activity={act} />
))}
</div>
</div>
))}

{/* Pagination controls */}
{hasMore && (
<div className="text-center pt-4">
<button
type="button"
onClick={() => fetchActivities(true)}
disabled={isLoadingMore}
className="inline-flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--card)] px-5 py-2.5 text-xs font-semibold text-[var(--foreground)] shadow-sm transition hover:bg-[var(--control)] disabled:opacity-50"
>
{isLoadingMore ? (
<svg className="animate-spin h-4 w-4 text-[var(--muted-foreground)]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : null}
{isLoadingMore ? "Loading more..." : "Load More Activities"}
</button>
</div>
)}
</div>
)}
</div>
);
}

function HelpCircleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
81 changes: 81 additions & 0 deletions src/components/dashboard/TimelineDateRangeControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import React from "react";
import type { DateRangeState } from "@/lib/timeline-formatter";

interface TimelineDateRangeControlsProps {
value: DateRangeState;
onChange: (value: DateRangeState) => void;
}

export default function TimelineDateRangeControls({
value,
onChange,
}: TimelineDateRangeControlsProps) {
const presets: { value: DateRangeState["preset"]; label: string }[] = [
{ value: "7", label: "Last 7 days" },
{ value: "30", label: "Last 30 days" },
{ value: "90", label: "Last 90 days" },
{ value: "custom", label: "Custom range" },
];

return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-1">
{presets.map((preset) => {
const isActive = value.preset === preset.value;
return (
<button
key={preset.value}
type="button"
onClick={() => {
onChange({
preset: preset.value,
startDate: preset.value === "custom" ? value.startDate || "" : undefined,
endDate: preset.value === "custom" ? value.endDate || "" : undefined,
});
}}
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition-colors ${
isActive
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]"
: "border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:bg-[var(--control)] hover:text-[var(--foreground)]"
}`}
>
{preset.label}
</button>
);
})}
</div>

{value.preset === "custom" && (
<div className="flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--card)] p-1.5 animate-in fade-in-50 duration-200">
<input
type="date"
aria-label="Start date"
value={value.startDate ?? ""}
onChange={(e) =>
onChange({
...value,
startDate: e.target.value,
})
}
className="bg-transparent text-xs text-[var(--foreground)] focus:outline-none"
/>
<span className="text-xs text-[var(--muted-foreground)]">to</span>
<input
type="date"
aria-label="End date"
value={value.endDate ?? ""}
onChange={(e) =>
onChange({
...value,
endDate: e.target.value,
})
}
className="bg-transparent text-xs text-[var(--foreground)] focus:outline-none"
/>
</div>
)}
</div>
);
}
Loading
Loading