diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 683c8fa..2d8a6fe 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,514 +1,5 @@ -"use client"; - -import Image from "next/image"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { FiActivity, FiFolder, FiGitPullRequest, FiRefreshCw } from "react-icons/fi"; -import { Tooltip } from "react-tooltip"; -import { IntegrationAccountsPanel } from "@/features/integrations/components/integration-accounts-panel"; -import { AuthenticatedLayout } from "@/shared/app-shell/authenticated-layout"; -import { ActivityList } from "@/features/dashboard/components/activity-list"; -import { ContributionChart } from "@/features/dashboard/components/contribution-chart"; -import { MetricCard } from "@/features/dashboard/components/metric-card"; -import { MetricsCarousel } from "@/features/dashboard/components/metrics-carousel"; -import { RepositoryTable } from "@/features/dashboard/components/repository-table"; -import { readApiJson } from "@/lib/api/response"; -import { notify } from "@/lib/notifications/toast"; -import { createSupabaseBrowserClient, isSupabaseConfigured } from "@/lib/supabase/client"; -import { - DashboardOverview, - DashboardSyncStatus, -} from "@/types/dashboard"; -import { DailyContribution, RepositorySummary } from "@/types/mock-app"; +import { DashboardScreen } from "@/features/dashboard/components/dashboard-screen"; export default function DashboardPage() { - const [overview, setOverview] = useState(null); - const [syncStatus, setSyncStatus] = useState(null); - const [viewerName, setViewerName] = useState("there"); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - - const loadOverview = useCallback(async (options?: { refresh?: boolean; silent?: boolean }) => { - const showLoading = !options?.silent && !options?.refresh; - - try { - if (showLoading) { - setIsLoading(true); - } - - if (options?.refresh) { - setIsRefreshing(true); - } - - if (!isSupabaseConfigured()) { - throw new Error("Configure Supabase to load dashboard data."); - } - - const supabase = createSupabaseBrowserClient(); - const { data } = await supabase.auth.getSession(); - - if (!data.session) { - return; - } - - const user = data.session.user; - const { data: profile } = await supabase - .from("profiles") - .select("full_name") - .eq("id", user.id) - .maybeSingle<{ full_name: string | null }>(); - - const metadataName = typeof user.user_metadata.full_name === "string" ? user.user_metadata.full_name : ""; - const fullName = profile?.full_name || metadataName || user.email || "there"; - setViewerName(getFirstName(fullName)); - - const url = "/api/dashboard/overview" + (options?.refresh ? "?refresh=1" : ""); - const response = await fetch(url, { - headers: { - Authorization: "Bearer " + data.session.access_token, - }, - }); - const result = await readApiJson<{ - overview?: DashboardOverview; - sync?: DashboardSyncStatus; - error?: string; - }>(response); - - if (!response.ok || !result.overview) { - throw new Error(result.error || "Unable to load dashboard data."); - } - - setOverview(result.overview); - setSyncStatus(result.sync ?? null); - } catch (error) { - notify({ type: "error", title: "Dashboard data failed", message: error instanceof Error ? error.message : "Unable to load dashboard data." }); - } finally { - if (showLoading) { - setIsLoading(false); - } - - if (options?.refresh) { - setIsRefreshing(false); - } - } - }, []); - - useEffect(() => { - loadOverview(); - const reloadOverview = () => loadOverview({ refresh: true }); - window.addEventListener("gitfusion:integrations-changed", reloadOverview); - return () => window.removeEventListener("gitfusion:integrations-changed", reloadOverview); - }, [loadOverview]); - - useEffect(() => { - if (syncStatus?.status !== "syncing") { - return; - } - - const intervalId = window.setInterval(() => { - loadOverview({ silent: true }); - }, 5000); - - return () => window.clearInterval(intervalId); - }, [loadOverview, syncStatus?.status]); - - const activeDays = overview?.dailyContributions.filter((day) => day.total > 0).length ?? 0; - const currentStreak = overview ? getCurrentStreak(overview.dailyContributions) : 0; - const dashboardMetrics = useMemo(() => overview ? buildDashboardMetrics(overview) : [], [overview]); - - return ( - loadOverview({ refresh: true })} - disabled={isRefreshing} - className="inline-flex h-10 items-center justify-center gap-2 px-3 text-sm font-bold text-muted-foreground transition hover:bg-muted hover:text-primary disabled:cursor-not-allowed disabled:opacity-60" - aria-label="Refresh dashboard data" - > - - Refresh - - } - > - {isLoading && } - - {!isLoading && overview && !overview.hasConnections && } - - {!isLoading && overview?.hasConnections && ( -
- - - {syncStatus?.status === "failed" && ( -
- loadOverview({ refresh: true })} isRefreshing={isRefreshing} /> -
- )} - - - {dashboardMetrics.map((metric) => ( - - ))} - - -
- -
- - -
-
- - -
- )} -
- ); -} - -function DashboardHero({ viewerName, activeDays, currentStreak, className = "" }: { viewerName: string; activeDays: number; currentStreak: number; className?: string }) { - return ( -
-
-
- - -
-
-

Dashboard

-

- Good to see you again, {viewerName}! 👋 -

- -
- 🔥 -

- {currentStreak} {currentStreak === 1 ? "day" : "days"} streak -

-
- -

- Here's your connected GitHub and GitLab activity overview. -

-
- -
-
- - 🔥 - -
-

Current streak

-

- {currentStreak} {currentStreak === 1 ? "day" : "days"} -

-
-
-

- {currentStreak > 0 ? "Nice momentum. Keep it going." : "No current streak yet."} -

-
- {Array.from({ length: 8 }).map((_, index) => ( - - ))} -
-
-
-
- ); -} - -function InlineSyncAlert({ syncStatus, onRetry, isRefreshing }: { syncStatus: DashboardSyncStatus; onRetry: () => void; isRefreshing: boolean }) { - return ( -
-
- ! -

- Provider data unavailable {syncStatus.errorMessage || "Unable to load dashboard data."} -

-
- -
- ); -} - -function ActivityOverview({ data }: { data: DailyContribution[] }) { - const months = getMonthlyActivity(data).slice(-7); - const maxValue = Math.max(...months.flatMap((month) => [month.github, month.gitlab]), 1); - const points = months.map((month, index) => ({ - x: months.length === 1 ? 0 : (index / (months.length - 1)) * 100, - githubY: 100 - (month.github / maxValue) * 82, - gitlabY: 100 - (month.gitlab / maxValue) * 82, - })); - const githubPoints = points.map((point) => `${point.x},${point.githubY}`).join(" "); - const gitlabPoints = points.map((point) => `${point.x},${point.gitlabY}`).join(" "); - - return ( -
-
-
-

Activity overview

-

Monthly contributions by provider.

-
-
- GitHub - GitLab -
-
- -
- - {[20, 45, 70, 95].map((y) => ( - - ))} - - - {points.map((point, index) => ( - - - - 96 ? 4 : 8} height="112" fill="transparent" /> - - ))} - -
-
- {months.map((month) => {month.label})} -
- -
- ); -} - -function LanguagesBreakdown({ repositories }: { repositories: RepositorySummary[] }) { - const languages = getLanguageBreakdown(repositories); - const gradient = languages.length - ? `conic-gradient(${languages.map((language) => `${language.color} ${language.start}% ${language.end}%`).join(", ")})` - : "conic-gradient(var(--muted) 0% 100%)"; - - return ( -
-

Languages

-
-
-
-
-
- {!languages.length &&

No repository language data was returned yet.

} - {languages.map((language) => ( -
- - {language.name} - {language.percent}% -
- ))} -
-
-
- ); -} - -function buildDashboardMetrics(overview: DashboardOverview) { - const total = getTotalContributions(overview.dailyContributions); - const githubTotal = overview.dailyContributions.reduce((sum, day) => sum + day.platforms.github, 0); - const gitlabTotal = overview.dailyContributions.reduce((sum, day) => sum + day.platforms.gitlab, 0); - const pullOrMergeRequests = overview.metrics.find((metric) => metric.label === "Pull / merge requests")?.value || "0"; - - return [ - { - label: "Total contributions", - value: total.toLocaleString("en-US"), - helper: `GitHub ${githubTotal.toLocaleString("en-US")} · GitLab ${gitlabTotal.toLocaleString("en-US")}`, - trend: `${overview.activeDays.toLocaleString("en-US")} active days`, - icon: FiActivity, - visual: "sparkline" as const, - }, - { - label: "Repositories", - value: overview.topRepositories.length.toLocaleString("en-US"), - helper: `Across ${overview.connectedProviders.length || 0} ${overview.connectedProviders.length === 1 ? "provider" : "providers"}`, - trend: "Loaded from providers", - icon: FiFolder, - visual: "bars" as const, - }, - { - label: "Pull / merge requests", - value: pullOrMergeRequests, - helper: "Detected from provider APIs", - trend: "Last year window", - icon: FiGitPullRequest, - visual: "bars" as const, - }, - ]; -} - -function getMonthlyActivity(data: DailyContribution[]) { - const monthFormatter = new Intl.DateTimeFormat("en-US", { month: "short" }); - const grouped = new Map(); - - data.forEach((day) => { - const date = parseLocalDate(day.date); - const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; - const current = grouped.get(key) ?? { label: monthFormatter.format(date), github: 0, gitlab: 0 }; - current.github += day.platforms.github; - current.gitlab += day.platforms.gitlab; - grouped.set(key, current); - }); - - return Array.from(grouped.values()); -} - -function getLanguageBreakdown(repositories: RepositorySummary[]) { - const totals = new Map(); - - repositories.forEach((repo) => { - const language = repo.language && repo.language !== "Unknown" ? repo.language : "Other"; - totals.set(language, (totals.get(language) || 0) + Math.max(repo.contributions, 1)); - }); - - const entries = Array.from(totals.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5); - const total = entries.reduce((sum, [, value]) => sum + value, 0); - let cursor = 0; - const colors = ["#67E8F9", "#A78BFA", "#F9A8D4", "#FBBF24", "#6EE7B7"]; - - return entries.map(([name, value], index) => { - const percent = total ? Math.round((value / total) * 100) : 0; - const start = cursor; - cursor += total ? (value / total) * 100 : 0; - - return { - name, - percent, - start, - end: cursor, - color: colors[index % colors.length], - }; - }); -} - -function getTotalContributions(data: DailyContribution[]) { - return data.reduce((sum, day) => sum + day.total, 0); -} - -function getFirstName(value: string) { - const firstName = value.trim().split(/\s+/).filter(Boolean)[0]; - - return firstName || "there"; -} - -function getCurrentStreak(days: DashboardOverview["dailyContributions"]) { - const sortedDays = [...days].sort((a, b) => b.date.localeCompare(a.date)); - - const firstActiveIndex = sortedDays.findIndex((day) => day.total > 0); - - if (firstActiveIndex === -1) { - return 0; - } - - const activeDays = sortedDays.slice(firstActiveIndex); - const firstInactiveIndex = activeDays.findIndex((day) => day.total <= 0); - - return firstInactiveIndex === -1 ? activeDays.length : firstInactiveIndex; -} - -function parseLocalDate(date: string) { - const [year, month, day] = date.split("-").map(Number); - return new Date(year, month - 1, day); -} - -function DashboardLoading() { - return ( -
-
- -
- - {Array.from({ length: 3 }).map((_, index) => ( -
- ))} - -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
- ); -} - -function EmptyConnectionsState() { - return ( -
-
-

No providers connected

-

Connect GitHub or GitLab to build your dashboard.

-

- Once at least one provider is connected, GitFusion will load repositories, daily activity, recent events, and overview metrics from that account. -

-
- -
- ); + return ; } diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx new file mode 100644 index 0000000..e0f18b4 --- /dev/null +++ b/src/app/demo/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { DashboardScreen } from "@/features/dashboard/components/dashboard-screen"; +import { buildDemoDashboardOverview, demoSyncStatus } from "@/features/demo/demo-dashboard"; + +export const metadata: Metadata = { + title: "Live demo · GitFusion", + description: "Explore the GitFusion dashboard with sample GitHub and GitLab activity.", +}; + +export default function DemoPage() { + return ( + + ); +} diff --git a/src/features/auth/components/auth-form.tsx b/src/features/auth/components/auth-form.tsx index 35d70c9..0d79f35 100644 --- a/src/features/auth/components/auth-form.tsx +++ b/src/features/auth/components/auth-form.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { ChangeEvent, FormEvent, useState } from "react"; import { createSupabaseBrowserClient, isSupabaseConfigured } from "@/lib/supabase/client"; import { uploadUserAvatar } from "@/lib/supabase/avatar"; +import { Alert, AlertDialog } from "@heroui/react"; type AuthMode = "sign-in" | "sign-up"; @@ -154,7 +155,7 @@ export function AuthForm({ mode }: AuthFormProps) { )} {message && ( -
+
{message}
)} @@ -167,6 +168,15 @@ export function AuthForm({ mode }: AuthFormProps) { {isSubmitting ? "Processing..." : isSignUp ? "Create account" : "Sign in"} + {!isSignUp && ( + + Explore the live demo + + )} +

{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "} diff --git a/src/features/dashboard/components/dashboard-screen.tsx b/src/features/dashboard/components/dashboard-screen.tsx new file mode 100644 index 0000000..f64a4fc --- /dev/null +++ b/src/features/dashboard/components/dashboard-screen.tsx @@ -0,0 +1,532 @@ +"use client"; + +import Image from "next/image"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { FiActivity, FiFolder, FiGitPullRequest, FiRefreshCw } from "react-icons/fi"; +import { Tooltip } from "react-tooltip"; +import { IntegrationAccountsPanel } from "@/features/integrations/components/integration-accounts-panel"; +import { AuthenticatedLayout } from "@/shared/app-shell/authenticated-layout"; +import { ActivityList } from "@/features/dashboard/components/activity-list"; +import { ContributionChart } from "@/features/dashboard/components/contribution-chart"; +import { MetricCard } from "@/features/dashboard/components/metric-card"; +import { MetricsCarousel } from "@/features/dashboard/components/metrics-carousel"; +import { RepositoryTable } from "@/features/dashboard/components/repository-table"; +import { readApiJson } from "@/lib/api/response"; +import { notify } from "@/lib/notifications/toast"; +import { createSupabaseBrowserClient, isSupabaseConfigured } from "@/lib/supabase/client"; +import { + DashboardOverview, + DashboardSyncStatus, +} from "@/types/dashboard"; +import { DailyContribution, RepositorySummary } from "@/types/mock-app"; + +type DashboardScreenProps = { + demo?: boolean; + initialOverview?: DashboardOverview; + initialSyncStatus?: DashboardSyncStatus; + initialViewerName?: string; +}; + +export function DashboardScreen({ + demo = false, + initialOverview, + initialSyncStatus, + initialViewerName = "there", +}: DashboardScreenProps) { + const [overview, setOverview] = useState(initialOverview ?? null); + const [syncStatus, setSyncStatus] = useState(initialSyncStatus ?? null); + const [viewerName, setViewerName] = useState(initialViewerName); + const [isLoading, setIsLoading] = useState(!initialOverview); + const [isRefreshing, setIsRefreshing] = useState(false); + + const loadOverview = useCallback(async (options?: { refresh?: boolean; silent?: boolean }) => { + if (demo) return; + + const showLoading = !options?.silent && !options?.refresh; + + try { + if (showLoading) { + setIsLoading(true); + } + + if (options?.refresh) { + setIsRefreshing(true); + } + + if (!isSupabaseConfigured()) { + throw new Error("Configure Supabase to load dashboard data."); + } + + const supabase = createSupabaseBrowserClient(); + const { data } = await supabase.auth.getSession(); + + if (!data.session) { + return; + } + + const user = data.session.user; + const { data: profile } = await supabase + .from("profiles") + .select("full_name") + .eq("id", user.id) + .maybeSingle<{ full_name: string | null }>(); + + const metadataName = typeof user.user_metadata.full_name === "string" ? user.user_metadata.full_name : ""; + const fullName = profile?.full_name || metadataName || user.email || "there"; + setViewerName(getFirstName(fullName)); + + const url = "/api/dashboard/overview" + (options?.refresh ? "?refresh=1" : ""); + const response = await fetch(url, { + headers: { + Authorization: "Bearer " + data.session.access_token, + }, + }); + const result = await readApiJson<{ + overview?: DashboardOverview; + sync?: DashboardSyncStatus; + error?: string; + }>(response); + + if (!response.ok || !result.overview) { + throw new Error(result.error || "Unable to load dashboard data."); + } + + setOverview(result.overview); + setSyncStatus(result.sync ?? null); + } catch (error) { + notify({ type: "error", title: "Dashboard data failed", message: error instanceof Error ? error.message : "Unable to load dashboard data." }); + } finally { + if (showLoading) { + setIsLoading(false); + } + + if (options?.refresh) { + setIsRefreshing(false); + } + } + }, [demo]); + + useEffect(() => { + if (demo) return; + + loadOverview(); + const reloadOverview = () => loadOverview({ refresh: true }); + window.addEventListener("gitfusion:integrations-changed", reloadOverview); + return () => window.removeEventListener("gitfusion:integrations-changed", reloadOverview); + }, [demo, loadOverview]); + + useEffect(() => { + if (demo || syncStatus?.status !== "syncing") { + return; + } + + const intervalId = window.setInterval(() => { + loadOverview({ silent: true }); + }, 5000); + + return () => window.clearInterval(intervalId); + }, [demo, loadOverview, syncStatus?.status]); + + const activeDays = overview?.dailyContributions.filter((day) => day.total > 0).length ?? 0; + const currentStreak = overview ? getCurrentStreak(overview.dailyContributions) : 0; + const dashboardMetrics = useMemo(() => overview ? buildDashboardMetrics(overview) : [], [overview]); + + return ( + loadOverview({ refresh: true })} + disabled={isRefreshing} + className="inline-flex h-10 items-center justify-center gap-2 px-3 text-sm font-bold text-muted-foreground transition hover:bg-muted hover:text-primary disabled:cursor-not-allowed disabled:opacity-60" + aria-label="Refresh dashboard data" + > + + Refresh + + ) : undefined} + > + {isLoading && } + + {!isLoading && overview && !overview.hasConnections && } + + {!isLoading && overview?.hasConnections && ( +

+ + + {syncStatus?.status === "failed" && ( +
+ loadOverview({ refresh: true })} isRefreshing={isRefreshing} /> +
+ )} + + + {dashboardMetrics.map((metric) => ( + + ))} + + +
+ +
+ + +
+
+ + +
+ )} + + ); +} + +function DashboardHero({ viewerName, activeDays, currentStreak, className = "" }: { viewerName: string; activeDays: number; currentStreak: number; className?: string }) { + return ( +
+
+
+ + +
+
+

Dashboard

+

+ Good to see you again, {viewerName}! 👋 +

+ +
+ 🔥 +

+ {currentStreak} {currentStreak === 1 ? "day" : "days"} streak +

+
+ +

+ Here's your connected GitHub and GitLab activity overview. +

+
+ +
+
+ + 🔥 + +
+

Current streak

+

+ {currentStreak} {currentStreak === 1 ? "day" : "days"} +

+
+
+

+ {currentStreak > 0 ? "Nice momentum. Keep it going." : "No current streak yet."} +

+
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+
+
+
+ ); +} + +function InlineSyncAlert({ syncStatus, onRetry, isRefreshing }: { syncStatus: DashboardSyncStatus; onRetry: () => void; isRefreshing: boolean }) { + return ( +
+
+ ! +

+ Provider data unavailable {syncStatus.errorMessage || "Unable to load dashboard data."} +

+
+ +
+ ); +} + +function ActivityOverview({ data }: { data: DailyContribution[] }) { + const months = getMonthlyActivity(data).slice(-7); + const maxValue = Math.max(...months.flatMap((month) => [month.github, month.gitlab]), 1); + const points = months.map((month, index) => ({ + x: months.length === 1 ? 0 : (index / (months.length - 1)) * 100, + githubY: 100 - (month.github / maxValue) * 82, + gitlabY: 100 - (month.gitlab / maxValue) * 82, + })); + const githubPoints = points.map((point) => `${point.x},${point.githubY}`).join(" "); + const gitlabPoints = points.map((point) => `${point.x},${point.gitlabY}`).join(" "); + + return ( +
+
+
+

Activity overview

+

Monthly contributions by provider.

+
+
+ GitHub + GitLab +
+
+ +
+ + {[20, 45, 70, 95].map((y) => ( + + ))} + + + {points.map((point, index) => ( + + + + 96 ? 4 : 8} height="112" fill="transparent" /> + + ))} + +
+
+ {months.map((month) => {month.label})} +
+ +
+ ); +} + +function LanguagesBreakdown({ repositories }: { repositories: RepositorySummary[] }) { + const languages = getLanguageBreakdown(repositories); + const gradient = languages.length + ? `conic-gradient(${languages.map((language) => `${language.color} ${language.start}% ${language.end}%`).join(", ")})` + : "conic-gradient(var(--muted) 0% 100%)"; + + return ( +
+

Languages

+
+
+
+
+
+ {!languages.length &&

No repository language data was returned yet.

} + {languages.map((language) => ( +
+ + {language.name} + {language.percent}% +
+ ))} +
+
+
+ ); +} + +function buildDashboardMetrics(overview: DashboardOverview) { + const total = getTotalContributions(overview.dailyContributions); + const githubTotal = overview.dailyContributions.reduce((sum, day) => sum + day.platforms.github, 0); + const gitlabTotal = overview.dailyContributions.reduce((sum, day) => sum + day.platforms.gitlab, 0); + const pullOrMergeRequests = overview.metrics.find((metric) => metric.label === "Pull / merge requests")?.value || "0"; + + return [ + { + label: "Total contributions", + value: total.toLocaleString("en-US"), + helper: `GitHub ${githubTotal.toLocaleString("en-US")} · GitLab ${gitlabTotal.toLocaleString("en-US")}`, + trend: `${overview.activeDays.toLocaleString("en-US")} active days`, + icon: FiActivity, + visual: "sparkline" as const, + }, + { + label: "Repositories", + value: overview.topRepositories.length.toLocaleString("en-US"), + helper: `Across ${overview.connectedProviders.length || 0} ${overview.connectedProviders.length === 1 ? "provider" : "providers"}`, + trend: "Loaded from providers", + icon: FiFolder, + visual: "bars" as const, + }, + { + label: "Pull / merge requests", + value: pullOrMergeRequests, + helper: "Detected from provider APIs", + trend: "Last year window", + icon: FiGitPullRequest, + visual: "bars" as const, + }, + ]; +} + +function getMonthlyActivity(data: DailyContribution[]) { + const monthFormatter = new Intl.DateTimeFormat("en-US", { month: "short" }); + const grouped = new Map(); + + data.forEach((day) => { + const date = parseLocalDate(day.date); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + const current = grouped.get(key) ?? { label: monthFormatter.format(date), github: 0, gitlab: 0 }; + current.github += day.platforms.github; + current.gitlab += day.platforms.gitlab; + grouped.set(key, current); + }); + + return Array.from(grouped.values()); +} + +function getLanguageBreakdown(repositories: RepositorySummary[]) { + const totals = new Map(); + + repositories.forEach((repo) => { + const language = repo.language && repo.language !== "Unknown" ? repo.language : "Other"; + totals.set(language, (totals.get(language) || 0) + Math.max(repo.contributions, 1)); + }); + + const entries = Array.from(totals.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5); + const total = entries.reduce((sum, [, value]) => sum + value, 0); + let cursor = 0; + const colors = ["#67E8F9", "#A78BFA", "#F9A8D4", "#FBBF24", "#6EE7B7"]; + + return entries.map(([name, value], index) => { + const percent = total ? Math.round((value / total) * 100) : 0; + const start = cursor; + cursor += total ? (value / total) * 100 : 0; + + return { + name, + percent, + start, + end: cursor, + color: colors[index % colors.length], + }; + }); +} + +function getTotalContributions(data: DailyContribution[]) { + return data.reduce((sum, day) => sum + day.total, 0); +} + +function getFirstName(value: string) { + const firstName = value.trim().split(/\s+/).filter(Boolean)[0]; + + return firstName || "there"; +} + +function getCurrentStreak(days: DashboardOverview["dailyContributions"]) { + const sortedDays = [...days].sort((a, b) => b.date.localeCompare(a.date)); + + const firstActiveIndex = sortedDays.findIndex((day) => day.total > 0); + + if (firstActiveIndex === -1) { + return 0; + } + + const activeDays = sortedDays.slice(firstActiveIndex); + const firstInactiveIndex = activeDays.findIndex((day) => day.total <= 0); + + return firstInactiveIndex === -1 ? activeDays.length : firstInactiveIndex; +} + +function parseLocalDate(date: string) { + const [year, month, day] = date.split("-").map(Number); + return new Date(year, month - 1, day); +} + +function DashboardLoading() { + return ( +
+
+ +
+ + {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ ); +} + +function EmptyConnectionsState() { + return ( +
+
+

No providers connected

+

Connect GitHub or GitLab to build your dashboard.

+

+ Once at least one provider is connected, GitFusion will load repositories, daily activity, recent events, and overview metrics from that account. +

+
+ +
+ ); +} diff --git a/src/features/demo/demo-dashboard.test.ts b/src/features/demo/demo-dashboard.test.ts new file mode 100644 index 0000000..4676e1f --- /dev/null +++ b/src/features/demo/demo-dashboard.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildDemoDashboardOverview } from "./demo-dashboard"; + +describe("buildDemoDashboardOverview", () => { + it("builds a complete, internally consistent year of sample data", () => { + const overview = buildDemoDashboardOverview(new Date(2026, 8, 20)); + + expect(overview.hasConnections).toBe(true); + expect(overview.connectedProviders).toEqual(["github", "gitlab"]); + expect(overview.dailyContributions).toHaveLength(365); + expect(overview.dailyContributions.at(-1)?.date).toBe("2026-09-20"); + expect(overview.dailyContributions.slice(-9).every((day) => day.total > 0)).toBe(true); + expect(overview.dailyContributions.at(-10)?.total).toBe(0); + expect(overview.dailyContributions.every((day) => day.total === day.platforms.github + day.platforms.gitlab)).toBe(true); + expect(overview.topRepositories.length).toBeGreaterThan(0); + expect(overview.recentActivity.length).toBeGreaterThan(0); + }); +}); diff --git a/src/features/demo/demo-dashboard.ts b/src/features/demo/demo-dashboard.ts new file mode 100644 index 0000000..14693c8 --- /dev/null +++ b/src/features/demo/demo-dashboard.ts @@ -0,0 +1,72 @@ +import type { DashboardOverview, DashboardSyncStatus } from "@/types/dashboard"; +import type { DailyContribution } from "@/types/mock-app"; + +const DAY_IN_MS = 86_400_000; +const DEMO_DAYS = 365; + +export const demoSyncStatus: DashboardSyncStatus = { + status: "synced", + progressPercent: 100, + startedAt: "2026-09-20T14:28:00.000Z", + finishedAt: "2026-09-20T14:28:08.000Z", + errorMessage: null, +}; + +export function buildDemoDashboardOverview(now = new Date()): DashboardOverview { + const dailyContributions = buildDailyContributions(now); + const activeDays = dailyContributions.filter((day) => day.total > 0).length; + + return { + hasConnections: true, + connectedProviders: ["github", "gitlab"], + metrics: [ + { label: "Pull / merge requests", value: "86", helper: "Across GitHub and GitLab", trend: "+14% from last period" }, + ], + activeDays, + currentStreak: 9, + dailyContributions, + recentActivity: [ + { id: "demo-1", title: "Merged pull request", description: "feat: add team activity insights · gitfusion/web", platform: "github", time: "2 hours ago" }, + { id: "demo-2", title: "Pushed 4 commits", description: "Improve dashboard accessibility · atlas-ui", platform: "gitlab", time: "Yesterday" }, + { id: "demo-3", title: "Opened pull request", description: "refactor: simplify provider adapters · gitfusion/api", platform: "github", time: "2 days ago" }, + { id: "demo-4", title: "Created release v2.4.0", description: "Performance and chart improvements · pulse", platform: "gitlab", time: "4 days ago" }, + { id: "demo-5", title: "Reviewed pull request", description: "Add repository filters · open-work", platform: "github", time: "5 days ago" }, + ], + topRepositories: [ + { name: "gitfusion", platform: "github", language: "TypeScript", contributions: 284, visibility: "Public" }, + { name: "atlas-ui", platform: "gitlab", language: "TypeScript", contributions: 193, visibility: "Private" }, + { name: "pulse-api", platform: "github", language: "Python", contributions: 146, visibility: "Public" }, + { name: "developer-portal", platform: "gitlab", language: "Vue", contributions: 121, visibility: "Private" }, + { name: "dotfiles", platform: "github", language: "Shell", contributions: 74, visibility: "Public" }, + { name: "event-worker", platform: "gitlab", language: "Go", contributions: 61, visibility: "Private" }, + ], + generatedAt: now.toISOString(), + }; +} + +function buildDailyContributions(now: Date): DailyContribution[] { + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + return Array.from({ length: DEMO_DAYS }, (_, index) => { + const daysAgo = DEMO_DAYS - index - 1; + const date = new Date(today.getTime() - daysAgo * DAY_IN_MS); + const seed = index + date.getMonth() * 11 + date.getDate() * 3; + const isRecentStreak = daysAgo < 9; + const isActive = isRecentStreak || (daysAgo !== 9 && seed % 7 !== 0); + const github = isActive ? (seed * 5) % 8 + (isRecentStreak ? 2 : 0) : 0; + const gitlab = isActive && seed % 3 !== 0 ? (seed * 3) % 5 : 0; + + return { + date: formatDate(date), + total: github + gitlab, + platforms: { github, gitlab }, + }; + }); +} + +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} diff --git a/src/features/integrations/components/account-connection-card.tsx b/src/features/integrations/components/account-connection-card.tsx index e940339..a19b471 100644 --- a/src/features/integrations/components/account-connection-card.tsx +++ b/src/features/integrations/components/account-connection-card.tsx @@ -9,9 +9,10 @@ type AccountConnectionCardProps = { onConnect: (provider: ConnectedAccount["provider"]) => void; onDisconnect?: (provider: ConnectedAccount["provider"]) => void; compact?: boolean; + readOnly?: boolean; }; -export function AccountConnectionCard({ account, onConnect, onDisconnect, compact = false }: AccountConnectionCardProps) { +export function AccountConnectionCard({ account, onConnect, onDisconnect, compact = false, readOnly = false }: AccountConnectionCardProps) { const isConnected = account.status === "connected"; const isConnecting = account.status === "connecting"; const Icon = account.provider === "github" ? FaGithub : FaGitlab; @@ -35,7 +36,7 @@ export function AccountConnectionCard({ account, onConnect, onDisconnect, compac {isConnected ? ( <> Connected - + } ) : ( diff --git a/src/features/integrations/components/integration-accounts-panel.tsx b/src/features/integrations/components/integration-accounts-panel.tsx index 946f556..8e8ed0b 100644 --- a/src/features/integrations/components/integration-accounts-panel.tsx +++ b/src/features/integrations/components/integration-accounts-panel.tsx @@ -22,6 +22,7 @@ type IntegrationAccountsPanelProps = { showContinue?: boolean; redirectTo?: string; className?: string; + demo?: boolean; }; const providerAccounts: ConnectedAccount[] = [ @@ -47,8 +48,13 @@ const providerAccounts: ConnectedAccount[] = [ }, ]; -export function IntegrationAccountsPanel({ compact = false, showContinue = false, redirectTo = "/connect-accounts", className = "" }: IntegrationAccountsPanelProps) { - const [accounts, setAccounts] = useState(() => buildAccounts([])); +const demoAccounts: ConnectedAccount[] = [ + { provider: "github", name: "GitHub", username: "alexdev", email: "", status: "connected", repositories: 18, contributions: 842, lastSync: "Today" }, + { provider: "gitlab", name: "GitLab", username: "alex.morgan", email: "", status: "connected", repositories: 7, contributions: 316, lastSync: "Today" }, +]; + +export function IntegrationAccountsPanel({ compact = false, showContinue = false, redirectTo = "/connect-accounts", className = "", demo = false }: IntegrationAccountsPanelProps) { + const [accounts, setAccounts] = useState(() => demo ? demoAccounts : buildAccounts([])); const loadAccounts = useCallback(async () => { if (!isSupabaseConfigured()) { @@ -81,6 +87,8 @@ export function IntegrationAccountsPanel({ compact = false, showContinue = false }, []); useEffect(() => { + if (demo) return; + const searchParams = new URLSearchParams(window.location.search); const connected = searchParams.get("connected"); const integrationError = searchParams.get("integration_error"); @@ -95,7 +103,7 @@ export function IntegrationAccountsPanel({ compact = false, showContinue = false } loadAccounts(); - }, [loadAccounts]); + }, [demo, loadAccounts]); const connectAccount = async (provider: AccountProvider) => { setAccounts((current) => current.map((account) => account.provider === provider ? { ...account, status: "connecting" } : account)); @@ -181,7 +189,7 @@ export function IntegrationAccountsPanel({ compact = false, showContinue = false
{accounts.map((account) => ( - + ))}
diff --git a/src/features/landing/components/hero-illustration.tsx b/src/features/landing/components/hero-illustration.tsx index f27db1f..9f359f1 100644 --- a/src/features/landing/components/hero-illustration.tsx +++ b/src/features/landing/components/hero-illustration.tsx @@ -1,233 +1,174 @@ import { FaGithub, FaGitlab } from "react-icons/fa6"; -import { FiAward, FiBarChart2, FiCompass, FiGitPullRequest, FiGrid, FiHome, FiSettings, FiTarget, FiZap } from "react-icons/fi"; +import { FiActivity, FiAward, FiBarChart2, FiCompass, FiFolder, FiGitPullRequest, FiGrid } from "react-icons/fi"; import { AppLogo } from "@/shared/components/app-logo"; const metrics = [ - { label: "Contributions", value: "2,482", trend: "+12%", icon: FiZap }, - { label: "Repositories", value: "30", trend: "+6%", icon: FiGrid }, - { label: "Pull requests", value: "86", trend: "+24%", icon: FiGitPullRequest }, - { label: "Current streak", value: "24 days", trend: "+4%", icon: FiTarget }, -]; + { label: "Total contributions", value: "2,482", helper: "GitHub 1,846 · GitLab 636", trend: "217 active days", icon: FiActivity, visual: "line" }, + { label: "Repositories", value: "18", helper: "Across 2 providers", trend: "Loaded from providers", icon: FiFolder, visual: "bars" }, + { label: "Pull / merge requests", value: "86", helper: "Detected from provider APIs", trend: "Last year window", icon: FiGitPullRequest, visual: "bars" }, +] as const; const navItems = [ - { label: "Dashboard", icon: FiHome, active: true }, - { label: "Integrations", icon: FiGrid }, - { label: "Analytics", icon: FiBarChart2 }, - { label: "Repositories", icon: FaGithub }, - { label: "Achievements", icon: FiAward }, - { label: "Explore", icon: FiCompass }, - { label: "Settings", icon: FiSettings }, -]; + { label: "Dashboard", icon: FiGrid, active: true }, + { label: "Repositories", icon: FiGitPullRequest, active: false }, + { label: "Analytics", icon: FiBarChart2, active: false }, + { label: "Achievements", icon: FiAward, active: false }, + { label: "Explore", icon: FiCompass, active: false }, +] as const; const repositories = [ - ["gitfusion", "TypeScript", 92, "1,234"], - ["portfolio", "TypeScript", 72, "892"], - ["jobflow", "PHP", 55, "654"], - ["dotfiles", "Shell", 30, "321"], - ["study-notes", "Markdown", 24, "287"], + ["gitfusion", "TypeScript", "284", FaGithub], + ["atlas-ui", "TypeScript", "193", FaGitlab], + ["pulse-api", "Python", "146", FaGithub], ] as const; const activities = [ - ["Pushed commits", "to gitfusion", "2h ago", FaGithub], - ["Opened a pull request", "in portfolio", "5h ago", FiGitPullRequest], - ["Merged pull request", "dotfiles", "1d ago", FaGithub], + ["Merged pull request", "gitfusion/web", "2h", FaGithub], + ["Pushed 4 commits", "atlas-ui", "1d", FaGitlab], + ["Opened pull request", "gitfusion/api", "2d", FaGithub], ] as const; const heatmap = Array.from({ length: 52 }, (_, week) => - Array.from({ length: 7 }, (_, day) => (week * 3 + day * 5 + (week % 5)) % 5), + Array.from({ length: 7 }, (_, day) => (week * 5 + day * 3 + (week % 4)) % 5), ); -const chartPoints = "0,108 70,92 140,120 210,38 280,105 350,102 420,72 490,112 560,60 630,92"; -const gitlabPoints = "0,112 70,88 140,72 210,96 280,66 350,94 420,86 490,82 560,78 630,58"; +const githubPoints = "0,91 70,74 140,88 210,38 280,84 350,62 420,70 490,34 560,49 630,22"; +const gitlabPoints = "0,98 70,83 140,70 210,82 280,61 350,76 420,58 490,66 560,44 630,52"; export function HeroIllustration() { return ( -
+
); } -function NavItem({ item }: { item: SidebarItem }) { +function NavItem({ item, demo }: { item: SidebarItem; demo: boolean }) { const pathname = usePathname(); const Icon = item.icon; - const isActive = item.href === pathname; + const href = demo && item.href === "/dashboard" ? "/demo" : item.href; + const isActive = href === pathname; const classes = `inline-flex h-9 items-center gap-2 rounded-full px-3 text-sm font-bold transition ${ isActive ? "bg-primary/18 text-primary ring-1 ring-primary/25" @@ -212,7 +236,7 @@ function NavItem({ item }: { item: SidebarItem }) { } return ( - + {item.label} @@ -250,11 +274,11 @@ function IconButton({ label, children }: { label: string; children: ReactNode }) ); } -function UserMenu() { +function UserMenu({ demo, demoUser }: { demo: boolean; demoUser?: UserDisplay }) { const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); - const user = useUserDisplay(); + const user = useUserDisplay(demoUser); useEffect(() => { if (!isOpen) { @@ -283,13 +307,13 @@ function UserMenu() { }, [isOpen]); const handleSignOut = async () => { - if (isSupabaseConfigured()) { + if (!demo && isSupabaseConfigured()) { const supabase = createSupabaseBrowserClient(); await supabase.auth.signOut(); } setIsOpen(false); - router.push("/sign-in"); + router.push(demo ? "/" : "/sign-in"); router.refresh(); }; @@ -298,7 +322,7 @@ function UserMenu() {
- setIsOpen(false)} @@ -327,7 +351,7 @@ function UserMenu() { > Settings - + }
)} @@ -344,13 +368,13 @@ function UserMenu() { ); } -function MobileMenu({ onNavigate }: { onNavigate: () => void }) { - const user = useUserDisplay(); +function MobileMenu({ onNavigate, demo, demoUser }: { onNavigate: () => void; demo: boolean; demoUser?: UserDisplay }) { + const user = useUserDisplay(demoUser); return ( <>
- + GitFusion @@ -361,9 +385,9 @@ function MobileMenu({ onNavigate }: { onNavigate: () => void }) {
@@ -379,10 +403,11 @@ function MobileMenu({ onNavigate }: { onNavigate: () => void }) { ); } -function MobileNavItem({ item, onNavigate }: { item: SidebarItem; onNavigate: () => void }) { +function MobileNavItem({ item, onNavigate, demo }: { item: SidebarItem; onNavigate: () => void; demo: boolean }) { const pathname = usePathname(); const Icon = item.icon; - const isActive = item.href === pathname; + const href = demo && item.href === "/dashboard" ? "/demo" : item.href; + const isActive = href === pathname; const classes = `flex w-full items-center gap-3 rounded-lg px-3 py-3 text-sm font-bold transition ${ isActive ? "bg-primary/18 text-primary ring-1 ring-primary/25" @@ -402,7 +427,7 @@ function MobileNavItem({ item, onNavigate }: { item: SidebarItem; onNavigate: () } return ( - + {item.label} @@ -541,7 +566,7 @@ function UserAvatar({ user }: { user: UserDisplay }) { ); } -function useUserDisplay() { +function useUserDisplay(override?: UserDisplay) { const [display, setDisplay] = useState({ name: "GitFusion user", username: "", @@ -549,6 +574,11 @@ function useUserDisplay() { }); useEffect(() => { + if (override) { + setDisplay(override); + return; + } + if (!isSupabaseConfigured()) { return; } @@ -582,9 +612,9 @@ function useUserDisplay() { loadUser(); window.addEventListener("gitfusion:profile-updated", loadUser); return () => window.removeEventListener("gitfusion:profile-updated", loadUser); - }, []); + }, [override]); - return display; + return override ?? display; } function getSyncSummary(sync: DashboardSyncStatus) {